Merge branch 'electrum-methods' into 'development'

Functional Electrum subscriptions

See merge request GeneralProtocols/xo/stack!59
This commit is contained in:
Jim Hamill
2025-04-12 05:05:08 +00:00
4 changed files with 176 additions and 32 deletions
@@ -17,7 +17,7 @@
v-if="state.selectedFromFunction?.extendedInput" v-if="state.selectedFromFunction?.extendedInput"
v-model="state.selectedFromFunction.models[i]" v-model="state.selectedFromFunction.models[i]"
class="extended-input" class="extended-input"
@input="autoGrow($event.target)" @input="(e) => autoGrow(e.target as HTMLTextAreaElement)"
></textarea> ></textarea>
<input <input
v-else v-else
@@ -37,11 +37,11 @@
<div v-if="state.toFunctions.length || state.primitive" class="section output-section"> <div v-if="state.toFunctions.length || state.primitive" class="section output-section">
<h2>{{ state.toFunctions.length ? 'Output' : 'Instance' }}</h2> <h2>{{ state.toFunctions.length ? 'Output' : 'Instance' }}</h2>
<template v-for="(toFunction, i) of toFunctions" :key="i"> <template v-for="(toFunction, i) of state.toFunctions" :key="i">
<div> <div>
<div class="output-box-container"> <div class="output-box-container">
<LabelledSection :label="toFunction.label" :content="getPrimitive(() => toFunction.callback(state.primitive))" /> <LabelledSection :label="toFunction.label" :content="getPrimitive(() => toFunction.callback(state.primitive!))" />
<CopyButton :content="getPrimitive(() => toFunction.callback(state.primitive))" /> <CopyButton :content="getPrimitive(() => toFunction.callback(state.primitive!))" />
</div> </div>
</div> </div>
</template> </template>
@@ -51,37 +51,51 @@
<div class="form-group select-group"> <div class="form-group select-group">
<label for="functionSelect">Method</label> <label for="functionSelect">Method</label>
<select v-model="state.selectedMethod"> <select v-model="state.selectedMethod">
<option v-for="method of props.methods" :key="method.label" :value="method">{{ method.label }}</option> <option v-for="method of state.methods" :key="method.label" :value="method">{{ method.label }}</option>
</select> </select>
</div> </div>
<div class="form-group input-group" :class="{ 'extended': state.selectedMethod?.extendedInput }"> <div v-if="state.selectedMethod" class="form-group input-group" :class="{ 'extended': state.selectedMethod.extendedInput }">
<template v-for="(_input, i) of state.selectedMethod.inputs" :key="i"> <template v-for="(_input, i) of state.selectedMethod.inputs" :key="i">
<label for="field">{{ state.selectedMethod.inputs[i] }}</label> <label for="field">{{ state.selectedMethod.inputs[i] }}</label>
<div> <div>
<textarea <div class="input-with-remove">
v-if="state.selectedMethod?.extendedInput" <textarea
v-model="state.selectedMethod.models[i]" v-if="state.selectedMethod.extendedInput"
class="extended-input" v-model="state.selectedMethod.models[i]"
@input="autoGrow($event.target)" class="extended-input"
></textarea> @input="(e) => autoGrow(e.target as HTMLTextAreaElement)"
<input ></textarea>
v-else <input
type="text" v-else
v-model="state.selectedMethod.models[i]" type="text"
/> v-model="state.selectedMethod.models[i]"
<div v-if="state.selectedMethod.error" class="text-error">{{ state.selectedMethod.error }}</div> />
<button
v-if="state.selectedMethod.dynamicInputs && !state.selectedMethod.inputs[i].length"
style="background: transparent"
class="remove-input-button"
@click="() => removeMethodInput(i)"
title="Remove input"
>
×
</button>
</div>
</div> </div>
</template> </template>
<div v-if="state.selectedMethod.error" class="text-error">{{ state.selectedMethod.error }}</div>
<div v-if="state.selectedMethod?.dynamicInputs" class="add-input-container">
<button class="flat-rounded-button" @click="addMethodInput">Add Parameter</button>
</div>
</div> </div>
</div> </div>
<div> <div>
<button type="submit" class="gradient-button" @click="() => executeMethod(state.selectedMethod?.callback(state.primitive, state.selectedMethod.models))">Execute Method</button> <button type="submit" class="gradient-button" @click="() => executeMethod(() => state.selectedMethod?.callback(state.primitive!, state.selectedMethod.models))">Execute Method</button>
</div> </div>
<!-- Result --> <!-- Result -->
<div> <div v-if="state.selectedMethod">
<div class="output-box-container"> <div class="output-box-container">
<LabelledSection label="Result" :content="state.selectedMethod.result" /> <LabelledSection label="Result" :content="state.selectedMethod.result || ''" />
<CopyButton :content="state.selectedMethod.result" /> <CopyButton :content="state.selectedMethod.result || ''" />
</div> </div>
</div> </div>
</template> </template>
@@ -134,6 +148,9 @@ export interface Method<Primitive> {
// Error (if one occurred). // Error (if one occurred).
error?: string; error?: string;
// Whether the number of inputs is dynamic.
dynamicInputs?: boolean;
// Function that takes the models and returns the primitive // Function that takes the models and returns the primitive
callback: (primitive: Primitive, value: Array<any>) => string | Promise<string>; callback: (primitive: Primitive, value: Array<any>) => string | Promise<string>;
@@ -191,7 +208,7 @@ async function setPrimitive(functionToExecute: () => Primitive | Promise<Primiti
state.value.error = undefined; state.value.error = undefined;
state.value.toFunctions.forEach(async (wrappedMethod, i) => { state.value.toFunctions.forEach(async (wrappedMethod, i) => {
state.value.toFunctions[i].result = await wrappedMethod.callback(state.value.primitive) state.value.toFunctions[i].result = await wrappedMethod.callback(state.value.primitive!)
}); });
state.value.methods.forEach((method, i) => { state.value.methods.forEach((method, i) => {
@@ -218,14 +235,20 @@ function getPrimitive(functionToExecute: () => string): string {
} }
} }
async function executeMethod(wrappedMethod: () => string | Promise<string>): Promise<void | string> { async function executeMethod(wrappedMethod: () => string | Promise<string> | undefined): Promise<void | string> {
if(!wrappedMethod || !state.value.selectedMethod) {
return;
}
try { try {
if(!state.value.primitive) { if(!state.value.primitive) {
return ''; return '';
} }
state.value.selectedMethod.result = await wrappedMethod; const result = await wrappedMethod();
// Update the result directly on the selected method
state.value.selectedMethod.result = result;
state.value.selectedMethod.error = undefined; state.value.selectedMethod.error = undefined;
} catch(error: any) { } catch(error: any) {
state.value.selectedMethod.error = error.message; state.value.selectedMethod.error = error.message;
@@ -234,10 +257,29 @@ async function executeMethod(wrappedMethod: () => string | Promise<string>): Pro
} }
}; };
async function addMethodInput() {
if(!state.value.selectedMethod) {
return;
}
state.value.selectedMethod.models.push('');
state.value.selectedMethod.inputs.push('');
}
async function removeMethodInput(index: number) {
if(!state.value.selectedMethod) {
return;
}
state.value.selectedMethod.models.splice(index, 1);
state.value.selectedMethod.inputs.splice(index, 1);
}
// Select the first "from" function. // Select the first "from" function.
state.value.selectedFromFunction = state.value.fromFunctions[0]; state.value.selectedFromFunction = state.value.fromFunctions[0];
const autoGrow = (element: HTMLTextAreaElement) => { const autoGrow = (element: HTMLTextAreaElement | null) => {
if (!element) return;
element.style.height = 'auto' element.style.height = 'auto'
element.style.height = `${Math.min(element.scrollHeight, 300)}px` element.style.height = `${Math.min(element.scrollHeight, 300)}px`
} }
+39 -1
View File
@@ -49,7 +49,7 @@
.form-group select, .form-group select,
.form-group input, .form-group input,
.form-group button { .form-group button:not(.remove-input-button) {
width: 100%; /* Makes fields responsive */ width: 100%; /* Makes fields responsive */
padding: 10px; padding: 10px;
font-size: 1rem; font-size: 1rem;
@@ -133,6 +133,44 @@
background: var(--primary); background: var(--primary);
} }
.add-input-container >button.flat-rounded-button {
border-radius: 100px;
cursor: pointer;
text-transform: uppercase;
border: none;
padding: 10px 20px;
width: auto;
font-size: 0.85rem;
font-weight: bold;
background: transparent;
}
.add-input-container > button.flat-rounded-button:hover {
background: var(--vp-c-divider); /* Super light grey */
}
.add-input-container {
display: flex;
justify-content: center;
padding-top: 10px;
}
.input-with-remove {
position: relative;
display: flex;
align-items: center;
}
.remove-input-button {
position: absolute;
right: 8px;
font-size: 20px;
}
.remove-input-button:hover {
color: #f46666;
}
/* Mobile responsiveness */ /* Mobile responsiveness */
@media (max-width: 768px) { @media (max-width: 768px) {
.select-group { .select-group {
@@ -3,6 +3,7 @@ import {
encodeExtendedJson, encodeExtendedJson,
Transaction, Transaction,
} from '@xocash/stack'; } from '@xocash/stack';
import { ref } from 'vue';
export const electrumClient = { export const electrumClient = {
from: [ from: [
@@ -19,6 +20,7 @@ export const electrumClient = {
application: 'XO-tools', application: 'XO-tools',
}, },
}); });
await electrum.start();
return electrum; return electrum;
}, },
@@ -26,10 +28,32 @@ export const electrumClient = {
], ],
to: [], to: [],
methods: [ methods: [
{
label: 'Request',
inputs: ['Method', 'Parameters'],
dynamicInputs: true,
models: ['', ''],
callback: async (
electrum: BlockchainElectrum,
args: [string, string],
) => {
const [method, ...parameters] = args;
// If the first parameter is empty, don't include it in the request
// This parameter can not be removed in the UI so we handle a blank parameter gracefully
// Since other parameters can be removed, we assume that blank parameters are intentional and so we include them as part of the request
if (!parameters[0] && parameters.length === 1) {
parameters.shift();
}
const response = await electrum.request(method, ...parameters);
return encodeExtendedJson(response, 2);
},
},
{ {
label: 'Fetch Unspents', label: 'Fetch Unspents',
inputs: ['Address'], inputs: ['Address'],
models: ['Address'], models: [''],
callback: async (electrum: BlockchainElectrum, args: [string]) => { callback: async (electrum: BlockchainElectrum, args: [string]) => {
const unspents = await electrum.fetchUnspents(args[0]); const unspents = await electrum.fetchUnspents(args[0]);
return encodeExtendedJson(unspents.toArray(), 2); return encodeExtendedJson(unspents.toArray(), 2);
@@ -38,7 +62,7 @@ export const electrumClient = {
{ {
label: 'Fetch Transaction', label: 'Fetch Transaction',
inputs: ['Transaction Hash'], inputs: ['Transaction Hash'],
models: ['Transaction Hash'], models: [''],
callback: async (electrum: BlockchainElectrum, args: [string]) => { callback: async (electrum: BlockchainElectrum, args: [string]) => {
const transaction = await electrum.fetchTransaction(args[0]); const transaction = await electrum.fetchTransaction(args[0]);
return encodeExtendedJson(transaction, 2); return encodeExtendedJson(transaction, 2);
@@ -47,7 +71,7 @@ export const electrumClient = {
{ {
label: 'Fetch Address History', label: 'Fetch Address History',
inputs: ['Address'], inputs: ['Address'],
models: ['Address'], models: [''],
callback: async (electrum: BlockchainElectrum, args: [string]) => { callback: async (electrum: BlockchainElectrum, args: [string]) => {
const transactions = await electrum.fetchAddressHistory(args[0]); const transactions = await electrum.fetchAddressHistory(args[0]);
return encodeExtendedJson(transactions.toArray(), 2); return encodeExtendedJson(transactions.toArray(), 2);
@@ -56,11 +80,50 @@ export const electrumClient = {
{ {
label: 'Broadcast Transaction', label: 'Broadcast Transaction',
inputs: ['Transaction'], inputs: ['Transaction'],
models: ['Transaction'], models: [''],
callback: async (electrum: BlockchainElectrum, args: [string]) => { callback: async (electrum: BlockchainElectrum, args: [string]) => {
const tx = Transaction.from(args[0]); const tx = Transaction.from(args[0]);
return await electrum.broadcastTransaction(tx); return await electrum.broadcastTransaction(tx);
}, },
}, },
{
label: 'Subscribe Address',
inputs: ['Address'],
models: [''],
callback: async (electrum: BlockchainElectrum, args: [string]) => {
// Use a vue ref so we can update the UI dynamically.
const addressEvents = ref<string>('');
await electrum.subscribeAddress(args[0], (status) => {
// Don't update if its null
if (!status) {
return;
}
addressEvents.value += `${status}\n`;
});
return addressEvents;
},
},
{
label: 'Subscribe Transaction',
inputs: ['Transaction Hash'],
models: [''],
callback: async (electrum: BlockchainElectrum, args: [string]) => {
const transactionEvents = ref<string>('');
await electrum.subscribeTransaction(args[0], (transaction) => {
// Don't update if its null
if (!transaction) {
return;
}
transactionEvents.value += `${transaction}\n`;
});
return transactionEvents;
},
},
], ],
}; };
@@ -27,4 +27,5 @@ import {
:toFunctions="electrumClient.to" :toFunctions="electrumClient.to"
:methods="electrumClient.methods" :methods="electrumClient.methods"
/> />
</div> </div>