import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types"; import type { Invitation } from "../services/invitation.js"; import { cashAddressToLockingBytecode, binToHex } from "@bitauth/libauth"; export interface SelectableUtxoLike { outpointTransactionHash: string; outpointIndex: number; valueSatoshis: bigint; lockingBytecode?: string; selected: boolean; } // TODO: Move to engine export const hasMissingRequirements = (missingRequirements: { variables?: string[]; inputs?: string[]; outputs?: string[]; roles?: Record; }): 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 => { const missingRequirements = await invitation.getMissingRequirements(); return !hasMissingRequirements(missingRequirements); }; // TODO: Move to engine in templates.ts 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) .filter((roleId) => roleId !== undefined); return [...new Set(roleIds)]; }; // TODO: Move to engine 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 actionRequirements = action.requirements; const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleIdentifier); const roleSlotsMin = actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 ? actionRoleRequirements.slots.min : 0; if (roleSlotsMin > 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, ""); /** * Checks whether a string looks like a CashAddress and, if so, converts it * to locking bytecode hex. Returns undefined when the value is not a * recognizable CashAddress (callers should fall through to treat it as raw hex). */ export const tryCashAddressToLockingBytecodeHex = ( value: string, ): string | undefined => { const trimmed = value.trim(); // Quick prefix check so we don't run the decoder on obvious hex strings. const looksLikeCashAddress = trimmed.startsWith("bitcoincash:") || trimmed.startsWith("bchtest:") || trimmed.startsWith("bchreg:") || // Handle prefix-less addresses (e.g. "qp..." or "pp...") /^[qpQP][a-zA-Z0-9]{41,}$/.test(trimmed); if (!looksLikeCashAddress) return undefined; const result = cashAddressToLockingBytecode(trimmed); // cashAddressToLockingBytecode returns a string on failure. if (typeof result === "string") return undefined; return binToHex(result.bytecode); }; // Replace with libauth compiler in the engine export const resolveProvidedLockingBytecodeHex = ( template: XOTemplate, outputIdentifier: string, variableValues: Record, ): string | undefined => { const outputDefinition = template.outputs?.[outputIdentifier]; if (!outputDefinition || typeof outputDefinition.lockingScript !== "string") { return undefined; } const lockingScriptDefinition = template.lockingScripts?.[outputDefinition.lockingScript]; const scriptIdentifier = lockingScriptDefinition?.lockingBytecode; if (!scriptIdentifier) return undefined; const scriptExpression = ( template.scripts as Record | 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; // If the user pasted a CashAddress, convert it to locking bytecode hex. const fromAddress = tryCashAddressToLockingBytecodeHex(providedValue); if (fromAddress) return fromAddress; 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(); 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; };