210 lines
6.5 KiB
TypeScript
210 lines
6.5 KiB
TypeScript
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<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);
|
|
};
|
|
|
|
// 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, string>,
|
|
): 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<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;
|
|
|
|
// 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<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;
|
|
};
|