Fix input filling
This commit is contained in:
BIN
Electrum.sqlite-journal
Normal file
BIN
Electrum.sqlite-journal
Normal file
Binary file not shown.
@@ -133,9 +133,15 @@ export class InvitationController extends EventEmitter {
|
|||||||
outpointTransactionHash: string;
|
outpointTransactionHash: string;
|
||||||
outpointIndex: number;
|
outpointIndex: number;
|
||||||
sequenceNumber?: number;
|
sequenceNumber?: number;
|
||||||
|
inputIdentifier?: string;
|
||||||
}>,
|
}>,
|
||||||
): Promise<TrackedInvitation> {
|
): Promise<TrackedInvitation> {
|
||||||
return this.flowManager.appendToInvitation(invitationId, { inputs });
|
// Ensure each input has an inputIdentifier (sync server requires it)
|
||||||
|
const inputsWithIds = inputs.map((input, index) => ({
|
||||||
|
...input,
|
||||||
|
inputIdentifier: input.inputIdentifier ?? `senderInput_${Date.now()}_${index}`,
|
||||||
|
}));
|
||||||
|
return this.flowManager.appendToInvitation(invitationId, { inputs: inputsWithIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,7 +158,12 @@ export class InvitationController extends EventEmitter {
|
|||||||
roleIdentifier?: string;
|
roleIdentifier?: string;
|
||||||
}>,
|
}>,
|
||||||
): Promise<TrackedInvitation> {
|
): Promise<TrackedInvitation> {
|
||||||
return this.flowManager.appendToInvitation(invitationId, { outputs });
|
// Ensure each output has an outputIdentifier (sync server may require it)
|
||||||
|
const outputsWithIds = outputs.map((output, index) => ({
|
||||||
|
...output,
|
||||||
|
outputIdentifier: output.outputIdentifier ?? `changeOutput_${Date.now()}_${index}`,
|
||||||
|
}));
|
||||||
|
return this.flowManager.appendToInvitation(invitationId, { outputs: outputsWithIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* Guides users through:
|
* Guides users through:
|
||||||
* - Reviewing action requirements
|
* - Reviewing action requirements
|
||||||
* - Entering variables (e.g., requestedSatoshis)
|
* - Entering variables (e.g., requestedSatoshis)
|
||||||
* - Reviewing outputs
|
* - Selecting inputs (UTXOs) for funding
|
||||||
|
* - Reviewing outputs and change
|
||||||
* - Creating and publishing invitation
|
* - Creating and publishing invitation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -12,17 +13,17 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from 'ink-text-input';
|
||||||
import { StepIndicator, type Step } from '../components/ProgressBar.js';
|
import { StepIndicator, type Step } from '../components/ProgressBar.js';
|
||||||
import { Button, ButtonRow } from '../components/Button.js';
|
import { Button } from '../components/Button.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { colors, logoSmall, formatSatoshis } from '../theme.js';
|
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
import { copyToClipboard } from '../utils/clipboard.js';
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate, XOInvitation } from '@xo-cash/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wizard step types.
|
* Wizard step types.
|
||||||
*/
|
*/
|
||||||
type StepType = 'info' | 'variables' | 'review' | 'publish';
|
type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wizard step definition.
|
* Wizard step definition.
|
||||||
@@ -43,6 +44,17 @@ interface VariableInput {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTXO for selection.
|
||||||
|
*/
|
||||||
|
interface SelectableUTXO {
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
lockingBytecode?: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action Wizard Screen Component.
|
* Action Wizard Screen Component.
|
||||||
*/
|
*/
|
||||||
@@ -57,15 +69,28 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
const roleIdentifier = navData.roleIdentifier as string | undefined;
|
const roleIdentifier = navData.roleIdentifier as string | undefined;
|
||||||
const template = navData.template as XOTemplate | undefined;
|
const template = navData.template as XOTemplate | undefined;
|
||||||
|
|
||||||
// State
|
// Wizard state
|
||||||
const [steps, setSteps] = useState<WizardStep[]>([]);
|
const [steps, setSteps] = useState<WizardStep[]>([]);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
// Variable inputs
|
||||||
const [variables, setVariables] = useState<VariableInput[]>([]);
|
const [variables, setVariables] = useState<VariableInput[]>([]);
|
||||||
|
|
||||||
|
// UTXO selection
|
||||||
|
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
|
||||||
|
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
||||||
|
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
|
||||||
|
const [fee, setFee] = useState<bigint>(500n); // Default fee estimate
|
||||||
|
|
||||||
|
// Invitation state
|
||||||
|
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
||||||
|
const [invitationId, setInvitationId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// UI state
|
||||||
const [focusedInput, setFocusedInput] = useState(0);
|
const [focusedInput, setFocusedInput] = useState(0);
|
||||||
const [focusedButton, setFocusedButton] = useState<'back' | 'cancel' | 'next'>('next');
|
const [focusedButton, setFocusedButton] = useState<'back' | 'cancel' | 'next'>('next');
|
||||||
const [focusArea, setFocusArea] = useState<'content' | 'buttons'>('content');
|
const [focusArea, setFocusArea] = useState<'content' | 'buttons'>('content');
|
||||||
const [invitationId, setInvitationId] = useState<string | null>(null);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize wizard on mount.
|
* Initialize wizard on mount.
|
||||||
@@ -104,6 +129,12 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
setVariables(varInputs);
|
setVariables(varInputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add inputs step if role requires slots (funding inputs)
|
||||||
|
// Slots indicate the role needs to provide transaction inputs/outputs
|
||||||
|
if (requirements?.slots && requirements.slots.min > 0) {
|
||||||
|
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
||||||
|
}
|
||||||
|
|
||||||
wizardSteps.push({ name: 'Review', type: 'review' });
|
wizardSteps.push({ name: 'Review', type: 'review' });
|
||||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
||||||
|
|
||||||
@@ -116,15 +147,123 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
*/
|
*/
|
||||||
const currentStepData = steps[currentStep];
|
const currentStepData = steps[currentStep];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate selected amount.
|
||||||
|
*/
|
||||||
|
const selectedAmount = availableUtxos
|
||||||
|
.filter(u => u.selected)
|
||||||
|
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate change amount.
|
||||||
|
*/
|
||||||
|
const changeAmount = selectedAmount - requiredAmount - fee;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load available UTXOs for the inputs step.
|
||||||
|
*/
|
||||||
|
const loadAvailableUtxos = useCallback(async () => {
|
||||||
|
if (!invitation || !templateIdentifier) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('Finding suitable UTXOs...');
|
||||||
|
|
||||||
|
// First, get the required amount from variables (e.g., requestedSatoshis)
|
||||||
|
const requestedVar = variables.find(v =>
|
||||||
|
v.id.toLowerCase().includes('satoshi') ||
|
||||||
|
v.id.toLowerCase().includes('amount')
|
||||||
|
);
|
||||||
|
const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n;
|
||||||
|
setRequiredAmount(requested);
|
||||||
|
|
||||||
|
// Find suitable resources
|
||||||
|
const resources = await walletController.findSuitableResources(invitation, {
|
||||||
|
templateIdentifier,
|
||||||
|
outputIdentifier: 'receiveOutput', // Common output identifier
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to selectable UTXOs
|
||||||
|
const utxos: SelectableUTXO[] = (resources.unspentOutputs || []).map((utxo: any) => ({
|
||||||
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
|
lockingBytecode: utxo.lockingBytecode
|
||||||
|
? typeof utxo.lockingBytecode === 'string'
|
||||||
|
? utxo.lockingBytecode
|
||||||
|
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||||
|
: undefined,
|
||||||
|
selected: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Auto-select UTXOs to cover required amount + fee
|
||||||
|
let accumulated = 0n;
|
||||||
|
const seenLockingBytecodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const utxo of utxos) {
|
||||||
|
// Ensure lockingBytecode uniqueness
|
||||||
|
if (utxo.lockingBytecode && seenLockingBytecodes.has(utxo.lockingBytecode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (utxo.lockingBytecode) {
|
||||||
|
seenLockingBytecodes.add(utxo.lockingBytecode);
|
||||||
|
}
|
||||||
|
|
||||||
|
utxo.selected = true;
|
||||||
|
accumulated += utxo.valueSatoshis;
|
||||||
|
|
||||||
|
if (accumulated >= requested + fee) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvailableUtxos(utxos);
|
||||||
|
setStatus('Ready');
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [invitation, templateIdentifier, variables, walletController, showError, setStatus]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle UTXO selection.
|
||||||
|
*/
|
||||||
|
const toggleUtxoSelection = useCallback((index: number) => {
|
||||||
|
setAvailableUtxos(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const utxo = updated[index];
|
||||||
|
if (utxo) {
|
||||||
|
updated[index] = { ...utxo, selected: !utxo.selected };
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to next step.
|
* Navigate to next step.
|
||||||
*/
|
*/
|
||||||
const nextStep = useCallback(async () => {
|
const nextStep = useCallback(async () => {
|
||||||
if (currentStep >= steps.length - 1) return;
|
if (currentStep >= steps.length - 1) return;
|
||||||
|
|
||||||
// If on review step, create invitation
|
const stepType = currentStepData?.type;
|
||||||
if (currentStepData?.type === 'review') {
|
|
||||||
await createInvitation();
|
// Handle step-specific logic
|
||||||
|
if (stepType === 'variables') {
|
||||||
|
// Create invitation and add variables
|
||||||
|
await createInvitationWithVariables();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepType === 'inputs') {
|
||||||
|
// Add selected inputs and outputs to invitation
|
||||||
|
await addInputsAndOutputs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepType === 'review') {
|
||||||
|
// Publish invitation
|
||||||
|
await publishInvitation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +272,135 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
setFocusedInput(0);
|
setFocusedInput(0);
|
||||||
}, [currentStep, steps.length, currentStepData]);
|
}, [currentStep, steps.length, currentStepData]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create invitation and add variables.
|
||||||
|
*/
|
||||||
|
const createInvitationWithVariables = useCallback(async () => {
|
||||||
|
if (!templateIdentifier || !actionIdentifier || !roleIdentifier) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('Creating invitation...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create invitation
|
||||||
|
const tracked = await invitationController.createInvitation(
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
let inv = tracked.invitation;
|
||||||
|
const invId = inv.invitationIdentifier;
|
||||||
|
setInvitationId(invId);
|
||||||
|
|
||||||
|
// Add variables if any
|
||||||
|
if (variables.length > 0) {
|
||||||
|
const variableData = variables.map(v => ({
|
||||||
|
variableIdentifier: v.id,
|
||||||
|
roleIdentifier: roleIdentifier,
|
||||||
|
value: v.type === 'number' || v.type === 'satoshis'
|
||||||
|
? BigInt(v.value || '0')
|
||||||
|
: v.value,
|
||||||
|
}));
|
||||||
|
const updated = await invitationController.addVariables(invId, variableData);
|
||||||
|
inv = updated.invitation;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInvitation(inv);
|
||||||
|
|
||||||
|
// Check if next step is inputs
|
||||||
|
const nextStepType = steps[currentStep + 1]?.type;
|
||||||
|
if (nextStepType === 'inputs') {
|
||||||
|
setCurrentStep(prev => prev + 1);
|
||||||
|
// Load UTXOs after step change
|
||||||
|
setTimeout(() => loadAvailableUtxos(), 100);
|
||||||
|
} else {
|
||||||
|
setCurrentStep(prev => prev + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Invitation created');
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [templateIdentifier, actionIdentifier, roleIdentifier, variables, invitationController, steps, currentStep, showError, setStatus, loadAvailableUtxos]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add selected inputs and change output to invitation.
|
||||||
|
*/
|
||||||
|
const addInputsAndOutputs = useCallback(async () => {
|
||||||
|
if (!invitationId || !invitation) return;
|
||||||
|
|
||||||
|
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
||||||
|
|
||||||
|
if (selectedUtxos.length === 0) {
|
||||||
|
showError('Please select at least one UTXO');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedAmount < requiredAmount + fee) {
|
||||||
|
showError(`Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeAmount < 546n) { // Dust threshold
|
||||||
|
showError(`Change amount (${changeAmount}) is below dust threshold (546 sats)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('Adding inputs and outputs...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add inputs
|
||||||
|
const inputs = selectedUtxos.map(utxo => ({
|
||||||
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await invitationController.addInputs(invitationId, inputs);
|
||||||
|
|
||||||
|
// Add change output
|
||||||
|
const outputs = [{
|
||||||
|
valueSatoshis: changeAmount,
|
||||||
|
// The engine will automatically generate the locking bytecode for change
|
||||||
|
}];
|
||||||
|
|
||||||
|
await invitationController.addOutputs(invitationId, outputs);
|
||||||
|
|
||||||
|
// Add transaction metadata
|
||||||
|
// Note: This would be done via appendInvitation but we don't have direct access here
|
||||||
|
// The engine should handle defaults
|
||||||
|
|
||||||
|
setCurrentStep(prev => prev + 1);
|
||||||
|
setStatus('Inputs and outputs added');
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, invitationController, showError, setStatus]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish invitation.
|
||||||
|
*/
|
||||||
|
const publishInvitation = useCallback(async () => {
|
||||||
|
if (!invitationId) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('Publishing invitation...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invitationController.publishAndSubscribe(invitationId);
|
||||||
|
setCurrentStep(prev => prev + 1);
|
||||||
|
setStatus('Invitation published');
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to publish: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [invitationId, invitationController, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to previous step.
|
* Navigate to previous step.
|
||||||
*/
|
*/
|
||||||
@@ -153,49 +421,6 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
goBack();
|
goBack();
|
||||||
}, [goBack]);
|
}, [goBack]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Create invitation.
|
|
||||||
*/
|
|
||||||
const createInvitation = useCallback(async () => {
|
|
||||||
if (!templateIdentifier || !actionIdentifier || !roleIdentifier) return;
|
|
||||||
|
|
||||||
setIsCreating(true);
|
|
||||||
setStatus('Creating invitation...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create invitation
|
|
||||||
const tracked = await invitationController.createInvitation(
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
);
|
|
||||||
|
|
||||||
const invId = tracked.invitation.invitationIdentifier;
|
|
||||||
setInvitationId(invId);
|
|
||||||
|
|
||||||
// Add variables if any
|
|
||||||
if (variables.length > 0) {
|
|
||||||
const variableData = variables.map(v => ({
|
|
||||||
variableIdentifier: v.id,
|
|
||||||
roleIdentifier: roleIdentifier,
|
|
||||||
value: v.type === 'number' || v.type === 'satoshis'
|
|
||||||
? BigInt(v.value || '0')
|
|
||||||
: v.value,
|
|
||||||
}));
|
|
||||||
await invitationController.addVariables(invId, variableData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish to sync server
|
|
||||||
await invitationController.publishAndSubscribe(invId);
|
|
||||||
|
|
||||||
setCurrentStep(prev => prev + 1);
|
|
||||||
setStatus('Invitation created');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
}, [templateIdentifier, actionIdentifier, roleIdentifier, variables, invitationController, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy invitation ID to clipboard.
|
* Copy invitation ID to clipboard.
|
||||||
*/
|
*/
|
||||||
@@ -229,17 +454,22 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
// Tab to switch between content and buttons
|
// Tab to switch between content and buttons
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
if (focusArea === 'content') {
|
if (focusArea === 'content') {
|
||||||
// In variables step, tab cycles through inputs first
|
// Handle tab based on current step type
|
||||||
if (currentStepData?.type === 'variables' && variables.length > 0) {
|
if (currentStepData?.type === 'variables' && variables.length > 0) {
|
||||||
if (focusedInput < variables.length - 1) {
|
if (focusedInput < variables.length - 1) {
|
||||||
setFocusedInput(prev => prev + 1);
|
setFocusedInput(prev => prev + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (currentStepData?.type === 'inputs' && availableUtxos.length > 0) {
|
||||||
|
if (selectedUtxoIndex < availableUtxos.length - 1) {
|
||||||
|
setSelectedUtxoIndex(prev => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setFocusArea('buttons');
|
setFocusArea('buttons');
|
||||||
setFocusedButton('next');
|
setFocusedButton('next');
|
||||||
} else {
|
} else {
|
||||||
// Cycle through buttons
|
|
||||||
if (focusedButton === 'back') {
|
if (focusedButton === 'back') {
|
||||||
setFocusedButton('cancel');
|
setFocusedButton('cancel');
|
||||||
} else if (focusedButton === 'cancel') {
|
} else if (focusedButton === 'cancel') {
|
||||||
@@ -247,31 +477,20 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
} else {
|
} else {
|
||||||
setFocusArea('content');
|
setFocusArea('content');
|
||||||
setFocusedInput(0);
|
setFocusedInput(0);
|
||||||
|
setSelectedUtxoIndex(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift+Tab
|
// Arrow keys for UTXO selection
|
||||||
if (key.shift && key.tab) {
|
if (focusArea === 'content' && currentStepData?.type === 'inputs') {
|
||||||
if (focusArea === 'buttons') {
|
if (key.upArrow) {
|
||||||
if (focusedButton === 'next') {
|
setSelectedUtxoIndex(prev => Math.max(0, prev - 1));
|
||||||
setFocusedButton('cancel');
|
} else if (key.downArrow) {
|
||||||
} else if (focusedButton === 'cancel') {
|
setSelectedUtxoIndex(prev => Math.min(availableUtxos.length - 1, prev + 1));
|
||||||
setFocusedButton('back');
|
} else if (key.return || input === ' ') {
|
||||||
} else {
|
toggleUtxoSelection(selectedUtxoIndex);
|
||||||
setFocusArea('content');
|
|
||||||
if (currentStepData?.type === 'variables' && variables.length > 0) {
|
|
||||||
setFocusedInput(variables.length - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (focusedInput > 0) {
|
|
||||||
setFocusedInput(prev => prev - 1);
|
|
||||||
} else {
|
|
||||||
setFocusArea('buttons');
|
|
||||||
setFocusedButton('back');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -300,6 +519,16 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
if (input === 'c' && currentStepData?.type === 'publish' && invitationId) {
|
if (input === 'c' && currentStepData?.type === 'publish' && invitationId) {
|
||||||
copyId();
|
copyId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 'a' to select all UTXOs
|
||||||
|
if (input === 'a' && currentStepData?.type === 'inputs') {
|
||||||
|
setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'n' to deselect all UTXOs
|
||||||
|
if (input === 'n' && currentStepData?.type === 'inputs') {
|
||||||
|
setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: false })));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get action details
|
// Get action details
|
||||||
@@ -321,13 +550,15 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
<Text color={colors.accent}>{roleIdentifier}</Text>
|
<Text color={colors.accent}>{roleIdentifier}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Show requirements */}
|
|
||||||
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
|
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text}>Requirements:</Text>
|
<Text color={colors.text}>Requirements:</Text>
|
||||||
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
|
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
|
||||||
<Text key={v} color={colors.textMuted}> • Variable: {v}</Text>
|
<Text key={v} color={colors.textMuted}> • Variable: {v}</Text>
|
||||||
))}
|
))}
|
||||||
|
{action.roles[roleIdentifier ?? '']?.requirements?.slots && (
|
||||||
|
<Text color={colors.textMuted}> • Slots: {action.roles[roleIdentifier ?? '']?.requirements?.slots?.min} min (UTXO selection required)</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -363,27 +594,95 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'inputs':
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.text} bold>Select UTXOs to fund the transaction:</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee
|
||||||
|
</Text>
|
||||||
|
<Text color={selectedAmount >= requiredAmount + fee ? colors.success : colors.warning}>
|
||||||
|
Selected: {formatSatoshis(selectedAmount)}
|
||||||
|
</Text>
|
||||||
|
{selectedAmount > requiredAmount + fee && (
|
||||||
|
<Text color={colors.info}>
|
||||||
|
Change: {formatSatoshis(changeAmount)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column" borderStyle="single" borderColor={colors.border} paddingX={1}>
|
||||||
|
{availableUtxos.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}>No UTXOs available</Text>
|
||||||
|
) : (
|
||||||
|
availableUtxos.map((utxo, index) => (
|
||||||
|
<Box key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}>
|
||||||
|
<Text
|
||||||
|
color={selectedUtxoIndex === index && focusArea === 'content' ? colors.focus : colors.text}
|
||||||
|
bold={selectedUtxoIndex === index && focusArea === 'content'}
|
||||||
|
>
|
||||||
|
{selectedUtxoIndex === index && focusArea === 'content' ? '▸ ' : ' '}
|
||||||
|
[{utxo.selected ? 'X' : ' '}] {formatSatoshis(utxo.valueSatoshis)} - {formatHex(utxo.outpointTransactionHash, 12)}:{utxo.outpointIndex}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Space/Enter: Toggle • a: Select all • n: Deselect all
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
case 'review':
|
case 'review':
|
||||||
|
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text color={colors.text} bold>Review your invitation:</Text>
|
<Text color={colors.text} bold>Review your invitation:</Text>
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
||||||
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
||||||
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
||||||
|
|
||||||
{variables.length > 0 && (
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
<Text color={colors.text}>Variables:</Text>
|
|
||||||
{variables.map(v => (
|
|
||||||
<Text key={v.id} color={colors.textMuted}>
|
|
||||||
{' '}{v.name}: {v.value || '(empty)'}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{variables.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Variables:</Text>
|
||||||
|
{variables.map(v => (
|
||||||
|
<Text key={v.id} color={colors.textMuted}>
|
||||||
|
{' '}{v.name}: {v.value || '(empty)'}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedUtxos.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Inputs ({selectedUtxos.length}):</Text>
|
||||||
|
{selectedUtxos.slice(0, 3).map(u => (
|
||||||
|
<Text key={`${u.outpointTransactionHash}:${u.outpointIndex}`} color={colors.textMuted}>
|
||||||
|
{' '}{formatSatoshis(u.valueSatoshis)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{selectedUtxos.length > 3 && (
|
||||||
|
<Text color={colors.textMuted}> ...and {selectedUtxos.length - 3} more</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changeAmount > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Outputs:</Text>
|
||||||
|
<Text color={colors.textMuted}> Change: {formatSatoshis(changeAmount)}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.warning}>
|
<Text color={colors.warning}>
|
||||||
Press Next to create and publish the invitation.
|
Press Next to create and publish the invitation.
|
||||||
@@ -395,7 +694,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
case 'publish':
|
case 'publish':
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text color={colors.success} bold>✓ Invitation Created!</Text>
|
<Text color={colors.success} bold>✓ Invitation Created & Published!</Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text}>Invitation ID:</Text>
|
<Text color={colors.text}>Invitation ID:</Text>
|
||||||
<Box
|
<Box
|
||||||
@@ -458,8 +757,8 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
{' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '}
|
{' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '}
|
||||||
</Text>
|
</Text>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
{isCreating ? (
|
{isProcessing ? (
|
||||||
<Text color={colors.info}>Creating invitation...</Text>
|
<Text color={colors.info}>Processing...</Text>
|
||||||
) : (
|
) : (
|
||||||
renderStepContent()
|
renderStepContent()
|
||||||
)}
|
)}
|
||||||
@@ -482,7 +781,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
<Button
|
<Button
|
||||||
label={currentStepData?.type === 'publish' ? 'Done' : 'Next'}
|
label={currentStepData?.type === 'publish' ? 'Done' : 'Next'}
|
||||||
focused={focusArea === 'buttons' && focusedButton === 'next'}
|
focused={focusArea === 'buttons' && focusedButton === 'next'}
|
||||||
disabled={isCreating}
|
disabled={isProcessing}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import TextInput from 'ink-text-input';
|
|||||||
import { InputDialog } from '../components/Dialog.js';
|
import { InputDialog } from '../components/Dialog.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { colors, logoSmall, formatHex } from '../theme.js';
|
import { colors, logoSmall, formatHex, formatSatoshis } from '../theme.js';
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
import { copyToClipboard } from '../utils/clipboard.js';
|
||||||
import type { TrackedInvitation, InvitationState } from '../../services/invitation-flow.js';
|
import type { TrackedInvitation, InvitationState } from '../../services/invitation-flow.js';
|
||||||
|
|
||||||
@@ -48,10 +48,11 @@ function getStateColor(state: InvitationState): string {
|
|||||||
*/
|
*/
|
||||||
const actionItems = [
|
const actionItems = [
|
||||||
{ label: 'Import Invitation', value: 'import' },
|
{ label: 'Import Invitation', value: 'import' },
|
||||||
{ label: 'Copy Invitation ID', value: 'copy' },
|
{ label: 'Accept & Join', value: 'accept' },
|
||||||
{ label: 'Accept Selected', value: 'accept' },
|
{ label: 'Fill Requirements', value: 'fill' },
|
||||||
{ label: 'Sign & Complete', value: 'sign' },
|
{ label: 'Sign Transaction', value: 'sign' },
|
||||||
{ label: 'View Transaction', value: 'transaction' },
|
{ label: 'View Transaction', value: 'transaction' },
|
||||||
|
{ label: 'Copy Invitation ID', value: 'copy' },
|
||||||
{ label: 'Refresh', value: 'refresh' },
|
{ label: 'Refresh', value: 'refresh' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ const actionItems = [
|
|||||||
*/
|
*/
|
||||||
export function InvitationScreen(): React.ReactElement {
|
export function InvitationScreen(): React.ReactElement {
|
||||||
const { navigate, data: navData } = useNavigation();
|
const { navigate, data: navData } = useNavigation();
|
||||||
const { invitationController, showError, showInfo } = useAppContext();
|
const { walletController, invitationController, showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -149,10 +150,16 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
await invitationController.acceptInvitation(selectedInvitation.invitation.invitationIdentifier);
|
await invitationController.acceptInvitation(selectedInvitation.invitation.invitationIdentifier);
|
||||||
loadInvitations();
|
loadInvitations();
|
||||||
showInfo('Invitation accepted! You are now a participant.');
|
showInfo('Invitation accepted! You are now a participant.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to accept: ${error instanceof Error ? error.message : String(error)}`);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
// Check if already accepted
|
||||||
|
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
|
||||||
|
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
||||||
|
} else {
|
||||||
|
showError(`Failed to accept: ${errorMsg}`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -199,6 +206,185 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError]);
|
}, [selectedInvitation, showInfo, showError]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill requirements for selected invitation.
|
||||||
|
* This automatically:
|
||||||
|
* 1. Accepts the invitation (if not already)
|
||||||
|
* 2. Finds suitable UTXOs
|
||||||
|
* 3. Selects UTXOs to cover the required amount
|
||||||
|
* 4. Appends inputs and change output to the invitation
|
||||||
|
*/
|
||||||
|
const fillRequirements = useCallback(async () => {
|
||||||
|
if (!selectedInvitation) {
|
||||||
|
showError('No invitation selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invId = selectedInvitation.invitation.invitationIdentifier;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Step 1: Check/show current state
|
||||||
|
setStatus('Checking invitation state...');
|
||||||
|
|
||||||
|
// Step 2: Accept invitation if not already accepted
|
||||||
|
setStatus('Accepting invitation...');
|
||||||
|
try {
|
||||||
|
await invitationController.acceptInvitation(invId);
|
||||||
|
} catch (e) {
|
||||||
|
// May already be accepted, continue
|
||||||
|
console.log('Accept error (may be already accepted):', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Get missing requirements
|
||||||
|
setStatus('Checking missing requirements...');
|
||||||
|
const missing = await invitationController.getMissingRequirements(invId);
|
||||||
|
missing.inputs
|
||||||
|
|
||||||
|
// Check if there are missing outputs that need inputs
|
||||||
|
const missingOutputs = missing.outputs;
|
||||||
|
const missingInputs = missing.inputs;
|
||||||
|
|
||||||
|
// If nothing is missing, we're done
|
||||||
|
if (!missingOutputs?.length && !missingInputs?.length) {
|
||||||
|
loadInvitations();
|
||||||
|
showInfo('No requirements to fill! The invitation may be ready to sign.');
|
||||||
|
setStatus('Ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Find suitable UTXOs
|
||||||
|
setStatus('Finding suitable UTXOs...');
|
||||||
|
|
||||||
|
// Get the tracked invitation with updated state
|
||||||
|
const tracked = invitationController.getInvitation(invId);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error('Invitation not found after accepting');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how much we need
|
||||||
|
// Look for a requestedSatoshis variable in the invitation
|
||||||
|
let requiredAmount = 0n;
|
||||||
|
const commits = tracked.invitation.commits || [];
|
||||||
|
for (const commit of commits) {
|
||||||
|
const variables = commit.data?.variables || [];
|
||||||
|
for (const variable of variables) {
|
||||||
|
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
|
||||||
|
requiredAmount = BigInt(variable.value?.toString() || '0');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (requiredAmount > 0n) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fee = 500n; // Estimated fee
|
||||||
|
const dust = 546n; // Dust threshold
|
||||||
|
const totalNeeded = requiredAmount + fee + dust;
|
||||||
|
|
||||||
|
|
||||||
|
// Find resources - use a common output identifier
|
||||||
|
const resources = await walletController.findSuitableResources(
|
||||||
|
tracked.invitation,
|
||||||
|
{
|
||||||
|
templateIdentifier: tracked.invitation.templateIdentifier,
|
||||||
|
outputIdentifier: 'receiveOutput', // Try common identifier
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const utxos = (resources as any)?.unspentOutputs || [];
|
||||||
|
|
||||||
|
if (utxos.length === 0) {
|
||||||
|
showError('No suitable UTXOs found. Make sure your wallet has funds.');
|
||||||
|
setStatus('Ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Select UTXOs (auto-select to cover the amount)
|
||||||
|
setStatus('Selecting UTXOs...');
|
||||||
|
|
||||||
|
const selectedUtxos: Array<{
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
}> = [];
|
||||||
|
let accumulated = 0n;
|
||||||
|
const seenLockingBytecodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const utxo of utxos) {
|
||||||
|
// Check lockingBytecode uniqueness
|
||||||
|
const lockingBytecodeHex = utxo.lockingBytecode
|
||||||
|
? typeof utxo.lockingBytecode === 'string'
|
||||||
|
? utxo.lockingBytecode
|
||||||
|
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lockingBytecodeHex) {
|
||||||
|
seenLockingBytecodes.add(lockingBytecodeHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedUtxos.push({
|
||||||
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
|
});
|
||||||
|
accumulated += BigInt(utxo.valueSatoshis);
|
||||||
|
|
||||||
|
if (accumulated >= totalNeeded) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accumulated < totalNeeded) {
|
||||||
|
showError(`Insufficient funds. Need ${formatSatoshis(totalNeeded)}, have ${formatSatoshis(accumulated)}`);
|
||||||
|
setStatus('Ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeAmount = accumulated - requiredAmount - fee;
|
||||||
|
|
||||||
|
// Step 6: Add inputs to the invitation
|
||||||
|
setStatus('Adding inputs...');
|
||||||
|
await invitationController.addInputs(
|
||||||
|
invId,
|
||||||
|
selectedUtxos.map(u => ({
|
||||||
|
outpointTransactionHash: u.outpointTransactionHash,
|
||||||
|
outpointIndex: u.outpointIndex,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 7: Add change output
|
||||||
|
if (changeAmount >= dust) {
|
||||||
|
setStatus('Adding change output...');
|
||||||
|
await invitationController.addOutputs(invId, [{
|
||||||
|
valueSatoshis: changeAmount,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload and show success
|
||||||
|
loadInvitations();
|
||||||
|
showInfo(
|
||||||
|
`Requirements filled!\n\n` +
|
||||||
|
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
||||||
|
`• Total: ${formatSatoshis(accumulated)}\n` +
|
||||||
|
`• Required: ${formatSatoshis(requiredAmount)}\n` +
|
||||||
|
`• Fee: ${formatSatoshis(fee)}\n` +
|
||||||
|
`• Change: ${formatSatoshis(changeAmount)}\n\n` +
|
||||||
|
`Now use "Sign Transaction" to complete.`
|
||||||
|
);
|
||||||
|
setStatus('Ready');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
setStatus('Ready');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedInvitation, invitationController, walletController, loadInvitations, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle action selection.
|
* Handle action selection.
|
||||||
*/
|
*/
|
||||||
@@ -213,6 +399,9 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
case 'accept':
|
case 'accept':
|
||||||
acceptInvitation();
|
acceptInvitation();
|
||||||
break;
|
break;
|
||||||
|
case 'fill':
|
||||||
|
fillRequirements();
|
||||||
|
break;
|
||||||
case 'sign':
|
case 'sign':
|
||||||
signInvitation();
|
signInvitation();
|
||||||
break;
|
break;
|
||||||
@@ -225,7 +414,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
loadInvitations();
|
loadInvitations();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [selectedInvitation, copyId, acceptInvitation, signInvitation, navigate, loadInvitations]);
|
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate, loadInvitations]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
@@ -338,8 +527,50 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
Action: {selectedInvitation.invitation.actionIdentifier}
|
Action: {selectedInvitation.invitation.actionIdentifier}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Commits: {selectedInvitation.invitation.commits?.length ?? 0}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* State-specific guidance */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{selectedInvitation.state === 'created' && (
|
||||||
|
<Text color={colors.info}>→ Share this ID with the other party</Text>
|
||||||
|
)}
|
||||||
|
{selectedInvitation.state === 'published' && (
|
||||||
|
<Text color={colors.info}>→ Waiting for other party to join...</Text>
|
||||||
|
)}
|
||||||
|
{selectedInvitation.state === 'pending' && (
|
||||||
|
<>
|
||||||
|
<Text color={colors.warning}>→ Action needed!</Text>
|
||||||
|
<Text color={colors.warning}> Use "Fill Requirements" to add</Text>
|
||||||
|
<Text color={colors.warning}> your UTXOs and complete your part</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedInvitation.state === 'ready' && (
|
||||||
|
<>
|
||||||
|
<Text color={colors.success}>→ Ready to sign!</Text>
|
||||||
|
<Text color={colors.success}> Use "Sign Transaction"</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedInvitation.state === 'signed' && (
|
||||||
|
<>
|
||||||
|
<Text color={colors.success}>→ Signed!</Text>
|
||||||
|
<Text color={colors.success}> View Transaction to broadcast</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedInvitation.state === 'broadcast' && (
|
||||||
|
<Text color={colors.success}>→ Transaction broadcast! Waiting for confirmation...</Text>
|
||||||
|
)}
|
||||||
|
{selectedInvitation.state === 'completed' && (
|
||||||
|
<Text color={colors.success}>✓ Transaction completed!</Text>
|
||||||
|
)}
|
||||||
|
{selectedInvitation.state === 'error' && (
|
||||||
|
<Text color={colors.error}>✗ Error - check logs</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.warning}>Press 'c' to copy ID</Text>
|
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user