Files
xo-cli/src/utils/invitation-flow.ts
2026-03-16 06:48:29 +00:00

153 lines
5.2 KiB
TypeScript

import type { XOTemplate, XOTemplateTransactionOutput } from '@xo-cash/types';
import type { Invitation } from '../services/invitation.js';
export interface SelectableUtxoLike {
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
lockingBytecode?: string;
selected: boolean;
}
export const hasMissingRequirements = (missingRequirements: {
variables?: string[];
inputs?: string[];
outputs?: string[];
roles?: Record<string, unknown>;
}): boolean => {
return (
(missingRequirements.variables?.length ?? 0) > 0
|| (missingRequirements.inputs?.length ?? 0) > 0
|| (missingRequirements.outputs?.length ?? 0) > 0
|| (missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0)
);
};
export const isInvitationRequirementsComplete = async (invitation: Invitation): Promise<boolean> => {
const missingRequirements = await invitation.getMissingRequirements();
return !hasMissingRequirements(missingRequirements);
};
export const resolveActionRoles = (
template: XOTemplate | undefined,
actionIdentifier: string | undefined,
rolesFromNavigation?: string[],
): string[] => {
if (rolesFromNavigation && rolesFromNavigation.length > 0) {
return [ ...new Set(rolesFromNavigation) ];
}
if (!template || !actionIdentifier) return [];
const starts = template.start ?? [];
const roleIds = starts
.filter((entry) => entry.action === actionIdentifier)
.map((entry) => entry.role);
return [ ...new Set(roleIds) ];
};
export const roleRequiresInputs = (
template: XOTemplate | undefined,
actionIdentifier: string | undefined,
roleIdentifier: string | undefined,
): boolean => {
if (!template || !actionIdentifier || !roleIdentifier) return false;
const action = template.actions?.[actionIdentifier];
if (!action) return false;
const actionRole = action.roles?.[roleIdentifier];
const roleSlotsMin = actionRole?.requirements?.slots?.min ?? 0;
if (roleSlotsMin > 0) return true;
// Some templates specify slot/input requirements at action.requirements.roles
// instead of role.requirements. Respect those as well.
const roleRequirement = action.requirements?.roles?.find((requirement) => requirement.role === roleIdentifier);
const actionLevelSlotsMin = roleRequirement?.slots?.min ?? 0;
if (actionLevelSlotsMin > 0) return true;
const transactionIdentifier = action.transaction;
const transaction = transactionIdentifier ? template.transactions?.[transactionIdentifier] : undefined;
const roleInputs = transaction?.roles?.[roleIdentifier]?.inputs;
return (roleInputs?.length ?? 0) > 0;
};
export const getTransactionOutputIdentifier = (output: XOTemplateTransactionOutput): string | undefined => {
if (typeof output === 'string') return output;
if (output && typeof output === 'object' && 'output' in output && typeof output.output === 'string') {
return output.output;
}
return undefined;
};
export const normalizeLockingBytecodeHex = (value: string): string => value.trim().replace(/^0x/i, '');
export const resolveProvidedLockingBytecodeHex = (
template: XOTemplate,
outputIdentifier: string,
variableValues: Record<string, string>,
): string | undefined => {
const outputDefinition = template.outputs?.[outputIdentifier];
if (!outputDefinition || typeof outputDefinition.lockscript !== 'string') return undefined;
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDefinition.lockscript] as
| { lockingScript?: string }
| undefined;
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
if (!scriptIdentifier) return undefined;
const scriptExpression = (template.scripts as Record<string, unknown> | undefined)?.[scriptIdentifier];
if (typeof scriptExpression !== 'string') return undefined;
const directVariableMatch = scriptExpression.match(/^<\s*([A-Za-z0-9_]+)\s*>$/);
if (!directVariableMatch) return undefined;
const variableIdentifier = directVariableMatch[1];
if (!variableIdentifier) return undefined;
const providedValue = variableValues[variableIdentifier];
if (!providedValue) return undefined;
return normalizeLockingBytecodeHex(providedValue);
};
export const mapUnspentOutputsToSelectable = (unspentOutputs: any[]): SelectableUtxoLike[] => {
return 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,
}));
};
export const autoSelectGreedyUtxos = (
utxos: SelectableUtxoLike[],
requiredWithFee: bigint,
): SelectableUtxoLike[] => {
let accumulated = 0n;
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
if (utxo.lockingBytecode && seenLockingBytecodes.has(utxo.lockingBytecode)) {
continue;
}
if (utxo.lockingBytecode) {
seenLockingBytecodes.add(utxo.lockingBytecode);
}
utxo.selected = true;
accumulated += utxo.valueSatoshis;
if (accumulated >= requiredWithFee) {
break;
}
}
return utxos;
};