Format with prettier. Use screen mode for invitation import - dialog mode is broken.
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"clipboardy": "^5.1.0",
|
||||
"ink": "^6.6.0",
|
||||
"prettier": "^3.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"zod": "^4.3.6"
|
||||
@@ -1984,6 +1985,21 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-ms": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"nuke": "tsx scripts/rm-dbs.ts",
|
||||
"nuke:dry": "tsx scripts/rm-dbs.ts --dry"
|
||||
"nuke:dry": "tsx scripts/rm-dbs.ts --dry",
|
||||
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"keywords": [
|
||||
"crypto",
|
||||
@@ -30,6 +32,7 @@
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"clipboardy": "^5.1.0",
|
||||
"ink": "^6.6.0",
|
||||
"prettier": "^3.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
import fs from 'fs/promises';
|
||||
import fs from "fs/promises";
|
||||
|
||||
/**
|
||||
* Remove all the databases without the use of external tools
|
||||
* TODO: Fix the ts linking issue here. Should just be adding this as a dir in tsconfig.json
|
||||
*/
|
||||
const rmDbs = async (dry = false) => {
|
||||
// First, we need to find all the database base files
|
||||
// These end in either .db.sqlite, .sqlite, .db
|
||||
// Get all the files in the current directory
|
||||
const files = await fs.readdir('./');
|
||||
// First, we need to find all the database base files
|
||||
// These end in either .db.sqlite, .sqlite, .db
|
||||
// Get all the files in the current directory
|
||||
const files = await fs.readdir("./");
|
||||
|
||||
// Filter out the files that end in .db.sqlite, .sqlite, .db
|
||||
const dbFiles = files.filter(file => file.endsWith('.db.sqlite') || file.endsWith('.sqlite') || file.endsWith('.db'));
|
||||
|
||||
// We need to remove all the files
|
||||
await deleteFiles(dbFiles, dry);
|
||||
}
|
||||
// Filter out the files that end in .db.sqlite, .sqlite, .db
|
||||
const dbFiles = files.filter(
|
||||
(file) =>
|
||||
file.endsWith(".db.sqlite") ||
|
||||
file.endsWith(".sqlite") ||
|
||||
file.endsWith(".db"),
|
||||
);
|
||||
|
||||
// We need to remove all the files
|
||||
await deleteFiles(dbFiles, dry);
|
||||
};
|
||||
|
||||
const deleteFiles = async (files: string[], dry = false) => {
|
||||
if (dry) {
|
||||
console.log('Dry run, would delete:', files);
|
||||
return
|
||||
}
|
||||
if (dry) {
|
||||
console.log("Dry run, would delete:", files);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(files.map(file => fs.rm(file)));
|
||||
console.log('All databases removed');
|
||||
}
|
||||
await Promise.all(files.map((file) => fs.rm(file)));
|
||||
console.log("All databases removed");
|
||||
};
|
||||
|
||||
// Read args
|
||||
const args = process.argv.slice(2);
|
||||
const dry = args.includes('--dry');
|
||||
const dry = args.includes("--dry");
|
||||
|
||||
// Delete the files
|
||||
await rmDbs(dry);
|
||||
await rmDbs(dry);
|
||||
|
||||
21
src/app.ts
21
src/app.ts
@@ -3,9 +3,9 @@
|
||||
* Simplified to render TUI immediately and let it handle AppService creation.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, type Instance } from 'ink';
|
||||
import { App as AppComponent } from './tui/App.js';
|
||||
import React from "react";
|
||||
import { render, type Instance } from "ink";
|
||||
import { App as AppComponent } from "./tui/App.js";
|
||||
|
||||
/**
|
||||
* Configuration options for the CLI application.
|
||||
@@ -28,7 +28,7 @@ export interface AppConfig {
|
||||
export class App {
|
||||
/** Ink render instance */
|
||||
private inkInstance: Instance | null = null;
|
||||
|
||||
|
||||
/** Application configuration */
|
||||
private config: AppConfig;
|
||||
|
||||
@@ -48,13 +48,14 @@ export class App {
|
||||
static async create(config: Partial<AppConfig> = {}): Promise<App> {
|
||||
// Set default configuration
|
||||
const fullConfig: AppConfig = {
|
||||
syncServerUrl: config.syncServerUrl ?? 'http://localhost:3000',
|
||||
databasePath: config.databasePath ?? './',
|
||||
databaseFilename: config.databaseFilename ?? 'xo-wallet.db',
|
||||
invitationStoragePath: config.invitationStoragePath ?? './xo-invitations.db',
|
||||
syncServerUrl: config.syncServerUrl ?? "http://localhost:3000",
|
||||
databasePath: config.databasePath ?? "./",
|
||||
databaseFilename: config.databaseFilename ?? "xo-wallet.db",
|
||||
invitationStoragePath:
|
||||
config.invitationStoragePath ?? "./xo-invitations.db",
|
||||
};
|
||||
|
||||
console.log('Full config:', fullConfig);
|
||||
console.log("Full config:", fullConfig);
|
||||
|
||||
const app = new App(fullConfig);
|
||||
await app.start();
|
||||
@@ -71,7 +72,7 @@ export class App {
|
||||
this.inkInstance = render(
|
||||
React.createElement(AppComponent, {
|
||||
config: this.config,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for the app to exit
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* XO Wallet CLI - Terminal User Interface for XO crypto wallet.
|
||||
*
|
||||
*
|
||||
* Features:
|
||||
* 1. View wallet state and balance
|
||||
* 2. Create invitations for P2PKH transactions
|
||||
@@ -9,7 +9,7 @@
|
||||
* 5. Real-time updates via SSE
|
||||
*/
|
||||
|
||||
import { App } from './app.js';
|
||||
import { App } from "./app.js";
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
@@ -18,12 +18,12 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
// Create and start the application
|
||||
await App.create({
|
||||
syncServerUrl: process.env['SYNC_SERVER_URL'] ?? 'http://localhost:3000',
|
||||
databasePath: process.env['DB_PATH'] ?? './',
|
||||
databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet.db',
|
||||
syncServerUrl: process.env["SYNC_SERVER_URL"] ?? "http://localhost:3000",
|
||||
databasePath: process.env["DB_PATH"] ?? "./",
|
||||
databaseFilename: process.env["DB_FILENAME"] ?? "xo-wallet.db",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start XO Wallet CLI:', error);
|
||||
console.error("Failed to start XO Wallet CLI:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import {
|
||||
import {
|
||||
Engine,
|
||||
type XOEngineOptions,
|
||||
// This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing
|
||||
generateTemplateIdentifier,
|
||||
} from '@xo-cash/engine';
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
} from "@xo-cash/engine";
|
||||
import type { XOInvitation } from "@xo-cash/types";
|
||||
|
||||
import { Invitation } from './invitation.js';
|
||||
import { Storage } from './storage.js';
|
||||
import { SyncServer } from '../utils/sync-server.js';
|
||||
import { HistoryService } from './history.js';
|
||||
import { ElectrumService } from './electrum.js';
|
||||
import { Invitation } from "./invitation.js";
|
||||
import { Storage } from "./storage.js";
|
||||
import { SyncServer } from "../utils/sync-server.js";
|
||||
import { HistoryService } from "./history.js";
|
||||
import { ElectrumService } from "./electrum.js";
|
||||
|
||||
import { EventEmitter } from '../utils/event-emitter.js';
|
||||
import { EventEmitter } from "../utils/event-emitter.js";
|
||||
|
||||
// TODO: Remove this. Exists to hash the seed for database namespace.
|
||||
import { createHash } from 'crypto';
|
||||
import { p2pkhTemplate } from '@xo-cash/templates';
|
||||
import { hexToBin } from '@bitauth/libauth';
|
||||
import { createHash } from "crypto";
|
||||
import { p2pkhTemplate } from "@xo-cash/templates";
|
||||
import { hexToBin } from "@bitauth/libauth";
|
||||
|
||||
export type AppEventMap = {
|
||||
'invitation-added': Invitation;
|
||||
'invitation-removed': Invitation;
|
||||
}
|
||||
"invitation-added": Invitation;
|
||||
"invitation-removed": Invitation;
|
||||
};
|
||||
|
||||
export interface AppConfig {
|
||||
syncServerUrl: string;
|
||||
@@ -38,14 +38,14 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
public config: AppConfig;
|
||||
public history: HistoryService;
|
||||
public electrum: ElectrumService;
|
||||
|
||||
|
||||
public invitations: Invitation[] = [];
|
||||
|
||||
|
||||
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
||||
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
||||
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
|
||||
const seedHash = createHash('sha256').update(seed).digest('hex');
|
||||
|
||||
const seedHash = createHash("sha256").update(seed).digest("hex");
|
||||
|
||||
// We want to only prefix the file name
|
||||
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
||||
|
||||
@@ -66,13 +66,13 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
|
||||
await engine.setDefaultLockingParameters(
|
||||
generateTemplateIdentifier(p2pkhTemplate),
|
||||
'receiveOutput',
|
||||
'receiver',
|
||||
"receiveOutput",
|
||||
"receiver",
|
||||
);
|
||||
|
||||
// Create our own storage for the invitations
|
||||
const storage = await Storage.create(config.invitationStoragePath);
|
||||
const walletStorage = await storage.child(seedHash.slice(0, 8))
|
||||
const walletStorage = await storage.child(seedHash.slice(0, 8));
|
||||
|
||||
// Create the app service
|
||||
const electrum = new ElectrumService({
|
||||
@@ -86,23 +86,35 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
const allUnspentOutputs = await engine.listUnspentOutputsData();
|
||||
|
||||
// Get a set of all the invitation identifiers
|
||||
const allInvitationIdentifiers = new Set(allUnspentOutputs.map(output => output.invitationIdentifier));
|
||||
const allInvitationIdentifiers = new Set(
|
||||
allUnspentOutputs.map((output) => output.invitationIdentifier),
|
||||
);
|
||||
|
||||
// Iterate over the invitation identifiers and unreserve the outputs
|
||||
for (const invitationIdentifier of allInvitationIdentifiers) {
|
||||
// Get the outputs for the invitation
|
||||
const outputs = allUnspentOutputs.filter(output => output.invitationIdentifier === invitationIdentifier);
|
||||
const outputs = allUnspentOutputs.filter(
|
||||
(output) => output.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
// Unreserve the outputs
|
||||
await engine.unreserveResources(outputs.map(output => ({
|
||||
outpointTransactionHash: hexToBin(output.outpointTransactionHash),
|
||||
outpointIndex: output.outpointIndex,
|
||||
})), invitationIdentifier);
|
||||
await engine.unreserveResources(
|
||||
outputs.map((output) => ({
|
||||
outpointTransactionHash: hexToBin(output.outpointTransactionHash),
|
||||
outpointIndex: output.outpointIndex,
|
||||
})),
|
||||
invitationIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
return new AppService(engine, walletStorage, config, electrum);
|
||||
}
|
||||
|
||||
constructor(engine: Engine, storage: Storage, config: AppConfig, electrum: ElectrumService) {
|
||||
constructor(
|
||||
engine: Engine,
|
||||
storage: Storage,
|
||||
config: AppConfig,
|
||||
electrum: ElectrumService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.engine = engine;
|
||||
@@ -112,10 +124,17 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
this.history = new HistoryService(engine, this.invitations);
|
||||
}
|
||||
|
||||
async createInvitation(invitation: XOInvitation | string): Promise<Invitation> {
|
||||
async createInvitation(
|
||||
invitation: XOInvitation | string,
|
||||
): Promise<Invitation> {
|
||||
// Make sure the engine has the template imported
|
||||
const invitationStorage = this.storage.child('invitations')
|
||||
const invitationSyncServer = new SyncServer(this.config.syncServerUrl, typeof invitation === 'string' ? invitation : invitation.invitationIdentifier);
|
||||
const invitationStorage = this.storage.child("invitations");
|
||||
const invitationSyncServer = new SyncServer(
|
||||
this.config.syncServerUrl,
|
||||
typeof invitation === "string"
|
||||
? invitation
|
||||
: invitation.invitationIdentifier,
|
||||
);
|
||||
|
||||
const deps = {
|
||||
engine: this.engine,
|
||||
@@ -138,26 +157,31 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
this.invitations.push(invitation);
|
||||
|
||||
// Emit the invitation-added event
|
||||
this.emit('invitation-added', invitation);
|
||||
this.emit("invitation-added", invitation);
|
||||
}
|
||||
|
||||
async removeInvitation(invitation: Invitation): Promise<void> {
|
||||
// Remove the invitation from the invitations array
|
||||
this.invitations = this.invitations.filter(i => i !== invitation);
|
||||
this.invitations = this.invitations.filter((i) => i !== invitation);
|
||||
|
||||
// Emit the invitation-removed event
|
||||
this.emit('invitation-removed', invitation);
|
||||
this.emit("invitation-removed", invitation);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Get the invitations db
|
||||
const invitationsDb = this.storage.child('invitations');
|
||||
const invitationsDb = this.storage.child("invitations");
|
||||
|
||||
// Load invitations from storage
|
||||
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
||||
const invitations = (await invitationsDb.all()) as {
|
||||
key: string;
|
||||
value: XOInvitation;
|
||||
}[];
|
||||
|
||||
await Promise.all(invitations.map(async ({ key }) => {
|
||||
await this.createInvitation(key);
|
||||
}));
|
||||
await Promise.all(
|
||||
invitations.map(async ({ key }) => {
|
||||
await this.createInvitation(key);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fetchTransactionBlockHeight, initializeElectrumClient } from '@electrum-cash/protocol';
|
||||
import {
|
||||
fetchTransactionBlockHeight,
|
||||
initializeElectrumClient,
|
||||
} from "@electrum-cash/protocol";
|
||||
|
||||
export interface ElectrumServiceConfig {
|
||||
host?: string;
|
||||
@@ -15,13 +18,17 @@ export class ElectrumService {
|
||||
private clientPromise?: ReturnType<typeof initializeElectrumClient>;
|
||||
|
||||
constructor(config: ElectrumServiceConfig = {}) {
|
||||
this.host = config.host ?? process.env['ELECTRUM_HOST'] ?? 'bch.imaginary.cash';
|
||||
this.applicationIdentifier = 'xo-cli';
|
||||
this.host =
|
||||
config.host ?? process.env["ELECTRUM_HOST"] ?? "bch.imaginary.cash";
|
||||
this.applicationIdentifier = "xo-cli";
|
||||
}
|
||||
|
||||
private async getClient() {
|
||||
if (!this.clientPromise) {
|
||||
this.clientPromise = initializeElectrumClient(this.applicationIdentifier, this.host);
|
||||
this.clientPromise = initializeElectrumClient(
|
||||
this.applicationIdentifier,
|
||||
this.host,
|
||||
);
|
||||
}
|
||||
|
||||
return this.clientPromise;
|
||||
@@ -38,7 +45,7 @@ export class ElectrumService {
|
||||
|
||||
// Electrum returns numbers for known transactions
|
||||
// (e.g. >0 confirmed, 0/-1 unconfirmed variants).
|
||||
return typeof height === 'number';
|
||||
return typeof height === "number";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { binToHex } from '@bitauth/libauth';
|
||||
import { compileCashAssemblyString, type Engine } from '@xo-cash/engine';
|
||||
import type { UnspentOutputData } from '@xo-cash/state';
|
||||
import type { XOInvitation, XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
import type { Invitation } from './invitation.js';
|
||||
import { binToHex } from "@bitauth/libauth";
|
||||
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
|
||||
import type { UnspentOutputData } from "@xo-cash/state";
|
||||
import type {
|
||||
XOInvitation,
|
||||
XOInvitationCommit,
|
||||
XOInvitationVariableValue,
|
||||
XOTemplate,
|
||||
} from "@xo-cash/types";
|
||||
import type { Invitation } from "./invitation.js";
|
||||
|
||||
export type HistoryEntryKind = 'invitation' | 'utxo';
|
||||
export type HistoryEntryKind = "invitation" | "utxo";
|
||||
|
||||
export interface HistoryDescriptionParts {
|
||||
template: string;
|
||||
@@ -15,7 +20,7 @@ export interface HistoryDescriptionParts {
|
||||
}
|
||||
|
||||
export interface HistoryUtxoItem {
|
||||
kind: 'utxo';
|
||||
kind: "utxo";
|
||||
id: string;
|
||||
invitationIdentifier?: string;
|
||||
templateIdentifier: string;
|
||||
@@ -26,13 +31,13 @@ export interface HistoryUtxoItem {
|
||||
};
|
||||
valueSatoshis?: bigint;
|
||||
reserved?: boolean;
|
||||
direction: 'input' | 'output' | 'standalone';
|
||||
direction: "input" | "output" | "standalone";
|
||||
description: string;
|
||||
descriptionParts: HistoryDescriptionParts;
|
||||
}
|
||||
|
||||
export interface HistoryInvitationItem {
|
||||
kind: 'invitation';
|
||||
kind: "invitation";
|
||||
id: string;
|
||||
createdAtTimestamp: number;
|
||||
templateIdentifier: string;
|
||||
@@ -65,7 +70,7 @@ interface UtxoOriginContext {
|
||||
export class HistoryService {
|
||||
constructor(
|
||||
private engine: Engine,
|
||||
private invitations: Invitation[]
|
||||
private invitations: Invitation[],
|
||||
) {}
|
||||
|
||||
async extractEntities(invitation: XOInvitation): Promise<string[]> {
|
||||
@@ -80,12 +85,15 @@ export class HistoryService {
|
||||
// Iterating through each commit, extract the entity into a Map<entityId: string, roles: string[]>.
|
||||
// While we iterate through the commits, if we see a role declaration in the commit, we save that role onto the entity's roles array.
|
||||
// After we have iterated through all the commits, we can return the Map<entityId: string, roles: string[]>.
|
||||
async matchRolesToEntities(invitation: XOInvitation, entities: string[]): Promise<Record<string, string[]>> {
|
||||
async matchRolesToEntities(
|
||||
invitation: XOInvitation,
|
||||
entities: string[],
|
||||
): Promise<Record<string, string[]>> {
|
||||
const entitiesMap = new Map<string, Set<string>>();
|
||||
for (const entity of entities) {
|
||||
entitiesMap.set(entity, new Set());
|
||||
}
|
||||
|
||||
|
||||
// First pass, we are just going to try and find roleIdentifer values in the inputs, outputs, and variables.
|
||||
// TODO: Update this once the invitations use XPubs
|
||||
for (const commit of invitation.commits) {
|
||||
@@ -125,7 +133,10 @@ export class HistoryService {
|
||||
const outpointValueSatoshis = new Map<string, bigint>();
|
||||
|
||||
for (const utxo of allUtxos) {
|
||||
const outpointKey = this.getOutpointKey(utxo.outpointTransactionHash, utxo.outpointIndex);
|
||||
const outpointKey = this.getOutpointKey(
|
||||
utxo.outpointTransactionHash,
|
||||
utxo.outpointIndex,
|
||||
);
|
||||
ownOutpoints.add(outpointKey);
|
||||
ownLockingBytecodes.add(utxo.lockingBytecode);
|
||||
outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis));
|
||||
@@ -134,8 +145,14 @@ export class HistoryService {
|
||||
const contexts = new Map<string, InvitationContext>();
|
||||
for (const invitation of this.invitations) {
|
||||
const variables = this.extractInvitationVariables(invitation.data);
|
||||
const template = await this.engine.getTemplate(invitation.data.templateIdentifier) ?? null;
|
||||
const walletEntityIdentifier = this.resolveWalletEntityIdentifier(invitation, ownOutpoints, ownLockingBytecodes);
|
||||
const template =
|
||||
(await this.engine.getTemplate(invitation.data.templateIdentifier)) ??
|
||||
null;
|
||||
const walletEntityIdentifier = this.resolveWalletEntityIdentifier(
|
||||
invitation,
|
||||
ownOutpoints,
|
||||
ownLockingBytecodes,
|
||||
);
|
||||
contexts.set(invitation.data.invitationIdentifier, {
|
||||
invitation,
|
||||
template,
|
||||
@@ -155,24 +172,32 @@ export class HistoryService {
|
||||
|
||||
for (const context of contexts.values()) {
|
||||
const invitation = context.invitation.data;
|
||||
const templateName = context.template?.name ?? 'UnknownTemplate';
|
||||
const templateName = context.template?.name ?? "UnknownTemplate";
|
||||
const invitationOutputs = this.buildWalletOutputItemsForInvitation(
|
||||
context,
|
||||
allUtxos,
|
||||
invitationByOrigin,
|
||||
usedUtxoIds,
|
||||
);
|
||||
const roles = this.deriveWalletRolesForInvitation(context, invitationOutputs);
|
||||
const roles = this.deriveWalletRolesForInvitation(
|
||||
context,
|
||||
invitationOutputs,
|
||||
);
|
||||
const invitationInputs = this.buildWalletInputItemsForInvitation(
|
||||
context,
|
||||
roles[0],
|
||||
invitationOutputs.length > 0,
|
||||
outpointValueSatoshis,
|
||||
);
|
||||
const invitationDescription = this.deriveInvitationDescription(invitation, context.template, context.variables, roles[0]);
|
||||
const invitationDescription = this.deriveInvitationDescription(
|
||||
invitation,
|
||||
context.template,
|
||||
context.variables,
|
||||
roles[0],
|
||||
);
|
||||
|
||||
invitationItems.push({
|
||||
kind: 'invitation',
|
||||
kind: "invitation",
|
||||
id: `inv-${invitation.invitationIdentifier}`,
|
||||
createdAtTimestamp: invitation.createdAtTimestamp,
|
||||
templateIdentifier: invitation.templateIdentifier,
|
||||
@@ -196,39 +221,66 @@ export class HistoryService {
|
||||
const utxoId = this.getUtxoId(utxo);
|
||||
if (usedUtxoIds.has(utxoId)) continue;
|
||||
|
||||
const template = await this.engine.getTemplate(utxo.templateIdentifier) ?? null;
|
||||
const inferredRole = this.inferRoleFromOutputIdentifier(utxo.outputIdentifier);
|
||||
const description = this.deriveUtxoDescription(utxo, template, {}, inferredRole);
|
||||
standaloneUtxos.push(this.buildUtxoHistoryItem(
|
||||
const template =
|
||||
(await this.engine.getTemplate(utxo.templateIdentifier)) ?? null;
|
||||
const inferredRole = this.inferRoleFromOutputIdentifier(
|
||||
utxo.outputIdentifier,
|
||||
);
|
||||
const description = this.deriveUtxoDescription(
|
||||
utxo,
|
||||
description,
|
||||
template?.name ?? 'UnknownTemplate',
|
||||
template,
|
||||
{},
|
||||
inferredRole,
|
||||
'standalone',
|
||||
));
|
||||
);
|
||||
standaloneUtxos.push(
|
||||
this.buildUtxoHistoryItem(
|
||||
utxo,
|
||||
description,
|
||||
template?.name ?? "UnknownTemplate",
|
||||
inferredRole,
|
||||
"standalone",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return [ ...invitationItems, ...standaloneUtxos ];
|
||||
return [...invitationItems, ...standaloneUtxos];
|
||||
}
|
||||
|
||||
private buildWalletOutputItemsForInvitation(
|
||||
context: InvitationContext,
|
||||
allUtxos: UnspentOutputData[],
|
||||
invitationByOrigin: Map<string, UtxoOriginContext>,
|
||||
usedUtxoIds: Set<string>
|
||||
usedUtxoIds: Set<string>,
|
||||
): HistoryUtxoItem[] {
|
||||
const invitationId = context.invitation.data.invitationIdentifier;
|
||||
const outputs: HistoryUtxoItem[] = [];
|
||||
|
||||
for (const utxo of allUtxos) {
|
||||
const resolvedInvitationId = this.resolveInvitationIdentifierForUtxo(utxo, invitationByOrigin);
|
||||
const resolvedInvitationId = this.resolveInvitationIdentifierForUtxo(
|
||||
utxo,
|
||||
invitationByOrigin,
|
||||
);
|
||||
if (resolvedInvitationId !== invitationId) continue;
|
||||
|
||||
const role = this.resolveRoleIdentifierForUtxo(utxo, invitationByOrigin)
|
||||
?? this.inferRoleFromOutputIdentifier(utxo.outputIdentifier)
|
||||
?? 'receiver';
|
||||
const description = this.deriveUtxoDescription(utxo, context.template, context.variables, role);
|
||||
outputs.push(this.buildUtxoHistoryItem(utxo, description, context.template?.name ?? 'UnknownTemplate', role, 'output'));
|
||||
const role =
|
||||
this.resolveRoleIdentifierForUtxo(utxo, invitationByOrigin) ??
|
||||
this.inferRoleFromOutputIdentifier(utxo.outputIdentifier) ??
|
||||
"receiver";
|
||||
const description = this.deriveUtxoDescription(
|
||||
utxo,
|
||||
context.template,
|
||||
context.variables,
|
||||
role,
|
||||
);
|
||||
outputs.push(
|
||||
this.buildUtxoHistoryItem(
|
||||
utxo,
|
||||
description,
|
||||
context.template?.name ?? "UnknownTemplate",
|
||||
role,
|
||||
"output",
|
||||
),
|
||||
);
|
||||
usedUtxoIds.add(this.getUtxoId(utxo));
|
||||
}
|
||||
|
||||
@@ -244,22 +296,40 @@ export class HistoryService {
|
||||
const invitation = context.invitation.data;
|
||||
const commits = invitation.commits ?? [];
|
||||
const commitsByEntity = context.walletEntityIdentifier
|
||||
? commits.filter((commit) => commit.entityIdentifier === context.walletEntityIdentifier)
|
||||
? commits.filter(
|
||||
(commit) =>
|
||||
commit.entityIdentifier === context.walletEntityIdentifier,
|
||||
)
|
||||
: [];
|
||||
const commitsByRole = walletRole
|
||||
? commits.filter((commit) => this.deriveCommitRoleIdentifier(commit, invitation, context.template) === walletRole)
|
||||
? commits.filter(
|
||||
(commit) =>
|
||||
this.deriveCommitRoleIdentifier(
|
||||
commit,
|
||||
invitation,
|
||||
context.template,
|
||||
) === walletRole,
|
||||
)
|
||||
: [];
|
||||
|
||||
let relevantCommits = commitsByEntity.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||
let relevantCommits = commitsByEntity.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
if (relevantCommits.length === 0) {
|
||||
relevantCommits = commitsByRole.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||
relevantCommits = commitsByRole.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
if (relevantCommits.length === 0 && walletRole === 'sender') {
|
||||
relevantCommits = commits.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||
if (relevantCommits.length === 0 && walletRole === "sender") {
|
||||
relevantCommits = commits.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
// Sender fallback only when no wallet outputs were matched.
|
||||
if (relevantCommits.length === 0 && !hasWalletOutputs) {
|
||||
relevantCommits = commits.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||
relevantCommits = commits.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
|
||||
const txDescription = this.deriveTransactionActivityDescription(
|
||||
@@ -273,19 +343,28 @@ export class HistoryService {
|
||||
for (const commit of relevantCommits) {
|
||||
for (const input of commit.data.inputs ?? []) {
|
||||
const txHash = input.outpointTransactionHash
|
||||
? (input.outpointTransactionHash instanceof Uint8Array
|
||||
? binToHex(input.outpointTransactionHash)
|
||||
: String(input.outpointTransactionHash))
|
||||
: 'unknown-tx';
|
||||
? input.outpointTransactionHash instanceof Uint8Array
|
||||
? binToHex(input.outpointTransactionHash)
|
||||
: String(input.outpointTransactionHash)
|
||||
: "unknown-tx";
|
||||
const inputIndex = input.outpointIndex ?? -1;
|
||||
const inputIdentifier = input.inputIdentifier ?? 'input';
|
||||
const inputDescription = this.deriveInputDescription(inputIdentifier, context.template, context.variables);
|
||||
const templateName = context.template?.name ?? 'UnknownTemplate';
|
||||
const role = walletRole ?? 'sender';
|
||||
const inputValue = this.resolveInputSatoshis(txHash, inputIndex, outpointValueSatoshis, context.variables);
|
||||
const inputIdentifier = input.inputIdentifier ?? "input";
|
||||
const inputDescription = this.deriveInputDescription(
|
||||
inputIdentifier,
|
||||
context.template,
|
||||
context.variables,
|
||||
);
|
||||
const templateName = context.template?.name ?? "UnknownTemplate";
|
||||
const role = walletRole ?? "sender";
|
||||
const inputValue = this.resolveInputSatoshis(
|
||||
txHash,
|
||||
inputIndex,
|
||||
outpointValueSatoshis,
|
||||
context.variables,
|
||||
);
|
||||
|
||||
inputs.push({
|
||||
kind: 'utxo',
|
||||
kind: "utxo",
|
||||
id: `input-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${txHash}:${inputIndex}-${inputIdentifier}`,
|
||||
invitationIdentifier: invitation.invitationIdentifier,
|
||||
templateIdentifier: invitation.templateIdentifier,
|
||||
@@ -294,7 +373,7 @@ export class HistoryService {
|
||||
txid: txHash,
|
||||
index: inputIndex,
|
||||
},
|
||||
direction: 'input',
|
||||
direction: "input",
|
||||
valueSatoshis: inputValue,
|
||||
description: `${txDescription} - ${inputDescription}`,
|
||||
descriptionParts: {
|
||||
@@ -302,7 +381,8 @@ export class HistoryService {
|
||||
role,
|
||||
outputIdentifier: inputIdentifier,
|
||||
description: `${txDescription} - ${inputDescription}`,
|
||||
valueSatoshis: inputValue !== undefined ? Number(inputValue) : undefined,
|
||||
valueSatoshis:
|
||||
inputValue !== undefined ? Number(inputValue) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -316,10 +396,10 @@ export class HistoryService {
|
||||
description: string,
|
||||
templateName: string,
|
||||
roleIdentifier: string | undefined,
|
||||
direction: HistoryUtxoItem['direction']
|
||||
direction: HistoryUtxoItem["direction"],
|
||||
): HistoryUtxoItem {
|
||||
return {
|
||||
kind: 'utxo',
|
||||
kind: "utxo",
|
||||
id: this.getUtxoId(utxo),
|
||||
invitationIdentifier: utxo.invitationIdentifier || undefined,
|
||||
templateIdentifier: utxo.templateIdentifier,
|
||||
@@ -334,7 +414,7 @@ export class HistoryService {
|
||||
description,
|
||||
descriptionParts: {
|
||||
template: templateName,
|
||||
role: roleIdentifier ?? 'unknown',
|
||||
role: roleIdentifier ?? "unknown",
|
||||
outputIdentifier: utxo.outputIdentifier,
|
||||
description,
|
||||
valueSatoshis: utxo.valueSatoshis,
|
||||
@@ -351,54 +431,80 @@ export class HistoryService {
|
||||
*/
|
||||
private deriveWalletRolesForInvitation(
|
||||
context: InvitationContext,
|
||||
outputs: HistoryUtxoItem[]
|
||||
outputs: HistoryUtxoItem[],
|
||||
): string[] {
|
||||
const roles = new Set<string>();
|
||||
for (const output of outputs) {
|
||||
const outputRole = output.descriptionParts.role;
|
||||
if (outputRole && outputRole !== 'unknown') {
|
||||
if (outputRole && outputRole !== "unknown") {
|
||||
roles.add(outputRole);
|
||||
}
|
||||
}
|
||||
if (roles.size === 0 && outputs.length > 0) {
|
||||
roles.add('receiver');
|
||||
roles.add("receiver");
|
||||
}
|
||||
|
||||
const hasInputCommit = (context.walletEntityIdentifier
|
||||
? context.invitation.data.commits.filter((c) => c.entityIdentifier === context.walletEntityIdentifier)
|
||||
: context.invitation.data.commits
|
||||
const hasInputCommit = (
|
||||
context.walletEntityIdentifier
|
||||
? context.invitation.data.commits.filter(
|
||||
(c) => c.entityIdentifier === context.walletEntityIdentifier,
|
||||
)
|
||||
: context.invitation.data.commits
|
||||
).some((c) => (c.data.inputs?.length ?? 0) > 0);
|
||||
|
||||
if (hasInputCommit) roles.add('sender');
|
||||
if (!hasInputCommit && outputs.length === 0 && context.invitation.data.commits.some((c) => (c.data.inputs?.length ?? 0) > 0)) {
|
||||
roles.add('sender');
|
||||
if (hasInputCommit) roles.add("sender");
|
||||
if (
|
||||
!hasInputCommit &&
|
||||
outputs.length === 0 &&
|
||||
context.invitation.data.commits.some(
|
||||
(c) => (c.data.inputs?.length ?? 0) > 0,
|
||||
)
|
||||
) {
|
||||
roles.add("sender");
|
||||
}
|
||||
if (roles.size === 0) {
|
||||
const inferred = this.extractInvitationRoleIdentifier(context.invitation.data, context.template, context.walletEntityIdentifier);
|
||||
const inferred = this.extractInvitationRoleIdentifier(
|
||||
context.invitation.data,
|
||||
context.template,
|
||||
context.walletEntityIdentifier,
|
||||
);
|
||||
if (inferred) roles.add(inferred);
|
||||
}
|
||||
|
||||
return roles.size > 0 ? Array.from(roles) : [ 'unknown' ];
|
||||
return roles.size > 0 ? Array.from(roles) : ["unknown"];
|
||||
}
|
||||
|
||||
private extractInvitationVariables(invitation: XOInvitation): Record<string, XOInvitationVariableValue> {
|
||||
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
|
||||
return committedVariables.reduce((acc, variable) => {
|
||||
if (!variable.variableIdentifier) return acc;
|
||||
acc[variable.variableIdentifier] = variable.value;
|
||||
return acc;
|
||||
}, {} as Record<string, XOInvitationVariableValue>);
|
||||
private extractInvitationVariables(
|
||||
invitation: XOInvitation,
|
||||
): Record<string, XOInvitationVariableValue> {
|
||||
const committedVariables = invitation.commits.flatMap(
|
||||
(c) => c.data.variables ?? [],
|
||||
);
|
||||
return committedVariables.reduce(
|
||||
(acc, variable) => {
|
||||
if (!variable.variableIdentifier) return acc;
|
||||
acc[variable.variableIdentifier] = variable.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, XOInvitationVariableValue>,
|
||||
);
|
||||
}
|
||||
|
||||
private indexInvitationOutputsByUtxoOrigin(
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
||||
invitation: Invitation
|
||||
invitation: Invitation,
|
||||
): void {
|
||||
for (const commit of invitation.data.commits) {
|
||||
for (const output of commit.data.outputs ?? []) {
|
||||
if (!output.outputIdentifier || !output.lockingBytecode) continue;
|
||||
const lockingBytecodeHex = this.toLockingBytecodeHex(output.lockingBytecode);
|
||||
const key = this.getUtxoOriginKey(invitation.data.templateIdentifier, output.outputIdentifier, lockingBytecodeHex);
|
||||
const lockingBytecodeHex = this.toLockingBytecodeHex(
|
||||
output.lockingBytecode,
|
||||
);
|
||||
const key = this.getUtxoOriginKey(
|
||||
invitation.data.templateIdentifier,
|
||||
output.outputIdentifier,
|
||||
lockingBytecodeHex,
|
||||
);
|
||||
invitationByUtxoOrigin.set(key, {
|
||||
invitationIdentifier: invitation.data.invitationIdentifier,
|
||||
roleIdentifier: output.roleIdentifier,
|
||||
@@ -409,25 +515,33 @@ export class HistoryService {
|
||||
|
||||
private resolveInvitationIdentifierForUtxo(
|
||||
utxo: UnspentOutputData,
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
||||
): string | undefined {
|
||||
if (utxo.invitationIdentifier) return utxo.invitationIdentifier;
|
||||
const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode);
|
||||
const originKey = this.getUtxoOriginKey(
|
||||
utxo.templateIdentifier,
|
||||
utxo.outputIdentifier,
|
||||
utxo.lockingBytecode,
|
||||
);
|
||||
return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier;
|
||||
}
|
||||
|
||||
private resolveRoleIdentifierForUtxo(
|
||||
utxo: UnspentOutputData,
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
||||
): string | undefined {
|
||||
const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode);
|
||||
const originKey = this.getUtxoOriginKey(
|
||||
utxo.templateIdentifier,
|
||||
utxo.outputIdentifier,
|
||||
utxo.lockingBytecode,
|
||||
);
|
||||
return invitationByUtxoOrigin.get(originKey)?.roleIdentifier;
|
||||
}
|
||||
|
||||
private resolveWalletEntityIdentifier(
|
||||
invitation: Invitation,
|
||||
ownUtxoOutpointKeys: Set<string>,
|
||||
ownLockingBytecodes: Set<string>
|
||||
ownLockingBytecodes: Set<string>,
|
||||
): string | undefined {
|
||||
const scores = new Map<string, number>();
|
||||
const addScore = (entityIdentifier: string, delta: number): void => {
|
||||
@@ -437,17 +551,23 @@ export class HistoryService {
|
||||
for (const commit of invitation.data.commits) {
|
||||
for (const input of commit.data.inputs ?? []) {
|
||||
const txHash = input.outpointTransactionHash
|
||||
? (input.outpointTransactionHash instanceof Uint8Array
|
||||
? binToHex(input.outpointTransactionHash)
|
||||
: String(input.outpointTransactionHash))
|
||||
? input.outpointTransactionHash instanceof Uint8Array
|
||||
? binToHex(input.outpointTransactionHash)
|
||||
: String(input.outpointTransactionHash)
|
||||
: undefined;
|
||||
if (!txHash || input.outpointIndex === undefined) continue;
|
||||
if (ownUtxoOutpointKeys.has(this.getOutpointKey(txHash, input.outpointIndex))) {
|
||||
if (
|
||||
ownUtxoOutpointKeys.has(
|
||||
this.getOutpointKey(txHash, input.outpointIndex),
|
||||
)
|
||||
) {
|
||||
addScore(commit.entityIdentifier, 3);
|
||||
}
|
||||
}
|
||||
for (const output of commit.data.outputs ?? []) {
|
||||
const lockingBytecodeHex = output.lockingBytecode ? this.toLockingBytecodeHex(output.lockingBytecode) : undefined;
|
||||
const lockingBytecodeHex = output.lockingBytecode
|
||||
? this.toLockingBytecodeHex(output.lockingBytecode)
|
||||
: undefined;
|
||||
if (!lockingBytecodeHex) continue;
|
||||
if (ownLockingBytecodes.has(lockingBytecodeHex)) {
|
||||
addScore(commit.entityIdentifier, 2);
|
||||
@@ -457,7 +577,7 @@ export class HistoryService {
|
||||
|
||||
let bestEntity: string | undefined;
|
||||
let bestScore = 0;
|
||||
for (const [ entity, score ] of scores.entries()) {
|
||||
for (const [entity, score] of scores.entries()) {
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestEntity = entity;
|
||||
@@ -470,17 +590,20 @@ export class HistoryService {
|
||||
utxo: UnspentOutputData,
|
||||
template: XOTemplate | null,
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
roleIdentifier?: string
|
||||
roleIdentifier?: string,
|
||||
): string {
|
||||
const templateName = template?.name ?? 'UnknownTemplate';
|
||||
const role = roleIdentifier ?? 'unknown';
|
||||
const templateName = template?.name ?? "UnknownTemplate";
|
||||
const role = roleIdentifier ?? "unknown";
|
||||
const outputDef = template?.outputs?.[utxo.outputIdentifier];
|
||||
let detail = outputDef?.name ?? utxo.outputIdentifier;
|
||||
if (outputDef?.description) {
|
||||
try {
|
||||
detail = compileCashAssemblyString(outputDef.description, variables);
|
||||
} catch {
|
||||
detail = this.interpolateSimpleCashAssemblyVariables(outputDef.description, variables);
|
||||
detail = this.interpolateSimpleCashAssemblyVariables(
|
||||
outputDef.description,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
}
|
||||
return `[${templateName}:${role}] ${detail}`;
|
||||
@@ -490,19 +613,28 @@ export class HistoryService {
|
||||
invitation: XOInvitation,
|
||||
template: XOTemplate | null,
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
roleIdentifier?: string
|
||||
roleIdentifier?: string,
|
||||
): string {
|
||||
if (!template) return invitation.actionIdentifier;
|
||||
const action = template.actions?.[invitation.actionIdentifier];
|
||||
const transactionName = action?.transaction;
|
||||
const transaction = transactionName ? template.transactions?.[transactionName] : null;
|
||||
const role = roleIdentifier ?? 'unknown';
|
||||
const baseTemplate = transaction?.description ?? action?.description ?? action?.name ?? invitation.actionIdentifier;
|
||||
const transaction = transactionName
|
||||
? template.transactions?.[transactionName]
|
||||
: null;
|
||||
const role = roleIdentifier ?? "unknown";
|
||||
const baseTemplate =
|
||||
transaction?.description ??
|
||||
action?.description ??
|
||||
action?.name ??
|
||||
invitation.actionIdentifier;
|
||||
let detail = baseTemplate;
|
||||
try {
|
||||
detail = compileCashAssemblyString(baseTemplate, variables);
|
||||
} catch {
|
||||
detail = this.interpolateSimpleCashAssemblyVariables(baseTemplate, variables);
|
||||
detail = this.interpolateSimpleCashAssemblyVariables(
|
||||
baseTemplate,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
return `[${template.name}:${role}] ${detail}`;
|
||||
}
|
||||
@@ -510,16 +642,19 @@ export class HistoryService {
|
||||
private deriveInputDescription(
|
||||
inputIdentifier: string,
|
||||
template: XOTemplate | null,
|
||||
variables: Record<string, XOInvitationVariableValue>
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
): string {
|
||||
if (inputIdentifier === 'input') return 'Funding input';
|
||||
if (inputIdentifier === "input") return "Funding input";
|
||||
const inputDef = template?.inputs?.[inputIdentifier];
|
||||
if (!inputDef) return inputIdentifier;
|
||||
if (!inputDef.description) return inputDef.name ?? inputIdentifier;
|
||||
try {
|
||||
return compileCashAssemblyString(inputDef.description, variables);
|
||||
} catch {
|
||||
return this.interpolateSimpleCashAssemblyVariables(inputDef.description, variables);
|
||||
return this.interpolateSimpleCashAssemblyVariables(
|
||||
inputDef.description,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,30 +662,38 @@ export class HistoryService {
|
||||
invitation: XOInvitation,
|
||||
template: XOTemplate | null,
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
roleIdentifier?: string
|
||||
roleIdentifier?: string,
|
||||
): string {
|
||||
if (!template) return invitation.actionIdentifier;
|
||||
const action = template.actions?.[invitation.actionIdentifier];
|
||||
const transactionName = action?.transaction;
|
||||
const transaction = transactionName ? template.transactions?.[transactionName] : null;
|
||||
const roleData = roleIdentifier ? transaction?.roles?.[roleIdentifier] : undefined;
|
||||
const descriptionTemplate = roleData?.description
|
||||
?? transaction?.description
|
||||
?? roleData?.name
|
||||
?? transaction?.name
|
||||
?? action?.name
|
||||
?? invitation.actionIdentifier;
|
||||
const transaction = transactionName
|
||||
? template.transactions?.[transactionName]
|
||||
: null;
|
||||
const roleData = roleIdentifier
|
||||
? transaction?.roles?.[roleIdentifier]
|
||||
: undefined;
|
||||
const descriptionTemplate =
|
||||
roleData?.description ??
|
||||
transaction?.description ??
|
||||
roleData?.name ??
|
||||
transaction?.name ??
|
||||
action?.name ??
|
||||
invitation.actionIdentifier;
|
||||
try {
|
||||
return compileCashAssemblyString(descriptionTemplate, variables);
|
||||
} catch {
|
||||
return this.interpolateSimpleCashAssemblyVariables(descriptionTemplate, variables);
|
||||
return this.interpolateSimpleCashAssemblyVariables(
|
||||
descriptionTemplate,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private deriveCommitRoleIdentifier(
|
||||
commit: XOInvitationCommit,
|
||||
invitation: XOInvitation,
|
||||
template: XOTemplate | null
|
||||
template: XOTemplate | null,
|
||||
): string | undefined {
|
||||
const explicitRoles = new Set<string>();
|
||||
for (const input of commit.data.inputs ?? []) {
|
||||
@@ -565,30 +708,42 @@ export class HistoryService {
|
||||
if (explicitRoles.size === 1) return Array.from(explicitRoles)[0];
|
||||
|
||||
const action = template?.actions?.[invitation.actionIdentifier];
|
||||
if ((commit.data.inputs?.length ?? 0) > 0 && action?.roles?.sender) return 'sender';
|
||||
if ((commit.data.variables?.length ?? 0) > 0 && action?.roles?.receiver) return 'receiver';
|
||||
if ((commit.data.inputs?.length ?? 0) > 0 && action?.roles?.sender)
|
||||
return "sender";
|
||||
if ((commit.data.variables?.length ?? 0) > 0 && action?.roles?.receiver)
|
||||
return "receiver";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractInvitationRoleIdentifier(
|
||||
invitation: XOInvitation,
|
||||
template: XOTemplate | null,
|
||||
walletEntityIdentifier?: string
|
||||
walletEntityIdentifier?: string,
|
||||
): string | undefined {
|
||||
if (walletEntityIdentifier) {
|
||||
const commits = invitation.commits.filter((commit) => commit.entityIdentifier === walletEntityIdentifier);
|
||||
const commits = invitation.commits.filter(
|
||||
(commit) => commit.entityIdentifier === walletEntityIdentifier,
|
||||
);
|
||||
for (const commit of commits) {
|
||||
const role = this.deriveCommitRoleIdentifier(commit, invitation, template);
|
||||
const role = this.deriveCommitRoleIdentifier(
|
||||
commit,
|
||||
invitation,
|
||||
template,
|
||||
);
|
||||
if (role) return role;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private inferRoleFromOutputIdentifier(outputIdentifier: string): string | undefined {
|
||||
private inferRoleFromOutputIdentifier(
|
||||
outputIdentifier: string,
|
||||
): string | undefined {
|
||||
const normalized = outputIdentifier.toLowerCase();
|
||||
if (normalized.includes('receive') || normalized.includes('request')) return 'receiver';
|
||||
if (normalized.includes('change') || normalized.includes('send')) return 'sender';
|
||||
if (normalized.includes("receive") || normalized.includes("request"))
|
||||
return "receiver";
|
||||
if (normalized.includes("change") || normalized.includes("send"))
|
||||
return "sender";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -596,7 +751,7 @@ export class HistoryService {
|
||||
txHash: string,
|
||||
index: number,
|
||||
outpointValueSatoshis: Map<string, bigint>,
|
||||
variables: Record<string, XOInvitationVariableValue>
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
): bigint | undefined {
|
||||
const outpointKey = this.getOutpointKey(txHash, index);
|
||||
const matchedValue = outpointValueSatoshis.get(outpointKey);
|
||||
@@ -622,22 +777,32 @@ export class HistoryService {
|
||||
return `${txid}:${index}`;
|
||||
}
|
||||
|
||||
private getUtxoOriginKey(templateIdentifier: string, outputIdentifier: string, lockingBytecodeHex: string): string {
|
||||
private getUtxoOriginKey(
|
||||
templateIdentifier: string,
|
||||
outputIdentifier: string,
|
||||
lockingBytecodeHex: string,
|
||||
): string {
|
||||
return `${templateIdentifier}:${outputIdentifier}:${lockingBytecodeHex}`;
|
||||
}
|
||||
|
||||
private toLockingBytecodeHex(lockingBytecode: string | Uint8Array): string {
|
||||
if (typeof lockingBytecode === 'string') return lockingBytecode;
|
||||
if (typeof lockingBytecode === "string") return lockingBytecode;
|
||||
return binToHex(lockingBytecode);
|
||||
}
|
||||
|
||||
private interpolateSimpleCashAssemblyVariables(
|
||||
text: string,
|
||||
variables: Record<string, XOInvitationVariableValue>
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
): string {
|
||||
return text.replace(/\$\(<([^>]+)>\)/g, (match, variableIdentifier: string) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) return match;
|
||||
return String(variables[variableIdentifier]);
|
||||
});
|
||||
return text.replace(
|
||||
/\$\(<([^>]+)>\)/g,
|
||||
(match, variableIdentifier: string) => {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)
|
||||
)
|
||||
return match;
|
||||
return String(variables[variableIdentifier]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,84 @@
|
||||
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
||||
import { hasInvitationExpired, mergeInvitationCommits } from '@xo-cash/engine';
|
||||
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable, XOInvitationVariableValue } from '@xo-cash/types';
|
||||
import type { UnspentOutputData } from '@xo-cash/state';
|
||||
import { binToHex, encodeTransaction, generateTransaction, hashTransaction, hexToBin } from '@bitauth/libauth';
|
||||
import type {
|
||||
AcceptInvitationParameters,
|
||||
AppendInvitationParameters,
|
||||
Engine,
|
||||
FindSuitableResourcesParameters,
|
||||
} from "@xo-cash/engine";
|
||||
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
||||
import type {
|
||||
XOInvitation,
|
||||
XOInvitationCommit,
|
||||
XOInvitationInput,
|
||||
XOInvitationOutput,
|
||||
XOInvitationVariable,
|
||||
XOInvitationVariableValue,
|
||||
} from "@xo-cash/types";
|
||||
import type { UnspentOutputData } from "@xo-cash/state";
|
||||
import {
|
||||
binToHex,
|
||||
encodeTransaction,
|
||||
generateTransaction,
|
||||
hashTransaction,
|
||||
hexToBin,
|
||||
} from "@bitauth/libauth";
|
||||
|
||||
import type { SSEvent } from '../utils/sse-client.js';
|
||||
import type { SyncServer } from '../utils/sync-server.js';
|
||||
import type { Storage } from './storage.js';
|
||||
import type { ElectrumService } from './electrum.js';
|
||||
import type { SSEvent } from "../utils/sse-client.js";
|
||||
import type { SyncServer } from "../utils/sync-server.js";
|
||||
import type { Storage } from "./storage.js";
|
||||
import type { ElectrumService } from "./electrum.js";
|
||||
|
||||
import { EventEmitter } from '../utils/event-emitter.js'
|
||||
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||
import { compileCashAssemblyString } from '@xo-cash/engine';
|
||||
import { EventEmitter } from "../utils/event-emitter.js";
|
||||
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
|
||||
import { compileCashAssemblyString } from "@xo-cash/engine";
|
||||
|
||||
export type InvitationEventMap = {
|
||||
'invitation-updated': XOInvitation;
|
||||
'invitation-status-changed': string;
|
||||
}
|
||||
"invitation-updated": XOInvitation;
|
||||
"invitation-status-changed": string;
|
||||
};
|
||||
|
||||
export type InvitationDependencies = {
|
||||
syncServer: SyncServer;
|
||||
storage: Storage;
|
||||
engine: Engine;
|
||||
electrum: ElectrumService;
|
||||
}
|
||||
};
|
||||
|
||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* Create an invitation and start the SSE Session required for it.
|
||||
*/
|
||||
static async create(invitation: XOInvitation | string, dependencies: InvitationDependencies): Promise<Invitation> {
|
||||
static async create(
|
||||
invitation: XOInvitation | string,
|
||||
dependencies: InvitationDependencies,
|
||||
): Promise<Invitation> {
|
||||
// If the invitation is a string, its probably an invitation identifier.
|
||||
// We will try to find the data then just call the create method again, but this time with the data.
|
||||
if(typeof invitation === 'string') {
|
||||
if (typeof invitation === "string") {
|
||||
// Try to get the invitation from the storage
|
||||
const invitationFromStorage = await dependencies.storage.get(invitation);
|
||||
if (invitationFromStorage) {
|
||||
return this.create(invitationFromStorage, dependencies);
|
||||
}
|
||||
|
||||
|
||||
// Try to get the invitation from the sync server
|
||||
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
||||
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
|
||||
const invitationFromSyncServer =
|
||||
await dependencies.syncServer.getInvitation(invitation);
|
||||
if (
|
||||
invitationFromSyncServer &&
|
||||
invitationFromSyncServer.invitationIdentifier === invitation
|
||||
) {
|
||||
return this.create(invitationFromSyncServer, dependencies);
|
||||
}
|
||||
|
||||
// We cant find it. Throw an error.
|
||||
throw new Error(`Invitation not found in local or remote storage: ${invitation}`);
|
||||
throw new Error(
|
||||
`Invitation not found in local or remote storage: ${invitation}`,
|
||||
);
|
||||
}
|
||||
|
||||
const template = await dependencies.engine.getTemplate(invitation.templateIdentifier);
|
||||
const template = await dependencies.engine.getTemplate(
|
||||
invitation.templateIdentifier,
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
||||
@@ -68,11 +97,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* The invitation data.
|
||||
*/
|
||||
public data: XOInvitation = {
|
||||
invitationIdentifier: '',
|
||||
invitationIdentifier: "",
|
||||
commits: [],
|
||||
createdAtTimestamp: 0,
|
||||
templateIdentifier: '',
|
||||
actionIdentifier: '',
|
||||
templateIdentifier: "",
|
||||
actionIdentifier: "",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -95,15 +124,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||
*/
|
||||
public status: string = 'unknown';
|
||||
public status: string = "unknown";
|
||||
|
||||
/**
|
||||
* Create an invitation and start the SSE Session required for it.
|
||||
*/
|
||||
constructor(
|
||||
invitation: XOInvitation,
|
||||
dependencies: InvitationDependencies
|
||||
) {
|
||||
constructor(invitation: XOInvitation, dependencies: InvitationDependencies) {
|
||||
super();
|
||||
|
||||
this.data = invitation;
|
||||
@@ -112,12 +138,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.storage = dependencies.storage;
|
||||
this.electrum = dependencies.electrum;
|
||||
|
||||
// I cannot express this enough, but the event handler does not need a clean up.
|
||||
// There is this beautiful thing called a "garbage collector". Once this class is removed from scope (removed from the invitations array) all the references
|
||||
// will be removed, including the SSE Session (and therefore this handler).
|
||||
this.syncServer.on('message', this.handleSSEMessage.bind(this));
|
||||
// Create a listerner for the messages from the SSE Session (sync server)
|
||||
this.syncServer.on("message", this.handleSSEMessage.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the invitation - Connect sync server and download latest invitation data.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Connect to the sync server and get the invitation (in parallel)
|
||||
const [_, invitation] = await Promise.all([
|
||||
@@ -129,7 +156,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
const sseCommits = this.data.commits;
|
||||
|
||||
// Merge the commits
|
||||
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
|
||||
const combinedCommits = this.mergeCommits(
|
||||
sseCommits,
|
||||
invitation?.commits ?? [],
|
||||
);
|
||||
|
||||
// Set the invitation data with the combined commits
|
||||
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
||||
@@ -146,15 +176,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
|
||||
/**
|
||||
* Handle an SSE message.
|
||||
*
|
||||
*
|
||||
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
|
||||
* Why this level of thought is required is beyond me. We should be given a `mergeCommits` method or "something" that lets us take whole invitation and merge commits into it.
|
||||
* NOTE: signInvitation does merge the commits... But we want to be able to add commits in *before* signing the invitation. So, we are just going to receive a single commit at a time, then just invitation.commits.push(commit); to get around this.
|
||||
* I hope we dont end up with duplicate commits :/... We also dont have a way to list invitiations, which is an... interesting choice.
|
||||
*/
|
||||
private handleSSEMessage(event: SSEvent): void {
|
||||
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||
if (data.topic === 'invitation-updated') {
|
||||
if (data.topic === "invitation-updated") {
|
||||
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
||||
|
||||
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
||||
@@ -162,7 +189,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
}
|
||||
|
||||
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
||||
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
||||
const newCommits = this.mergeCommits(
|
||||
this.data.commits,
|
||||
invitation.commits,
|
||||
);
|
||||
|
||||
// Set the new commits
|
||||
this.data = { ...this.data, commits: newCommits };
|
||||
@@ -171,7 +201,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.updateStatus().catch(() => {});
|
||||
|
||||
// Emit the updated event
|
||||
this.emit('invitation-updated', this.data);
|
||||
this.emit("invitation-updated", this.data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,16 +211,19 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* @param additional - The additional commits
|
||||
* @returns The merged commits
|
||||
*/
|
||||
private mergeCommits(initial: XOInvitationCommit[], additional: XOInvitationCommit[]): XOInvitationCommit[] {
|
||||
private mergeCommits(
|
||||
initial: XOInvitationCommit[],
|
||||
additional: XOInvitationCommit[],
|
||||
): XOInvitationCommit[] {
|
||||
// Create a map of the initial commits
|
||||
const initialMap = new Map<string, XOInvitationCommit>();
|
||||
for(const commit of initial) {
|
||||
for (const commit of initial) {
|
||||
initialMap.set(commit.commitIdentifier, commit);
|
||||
}
|
||||
|
||||
// Merge the additional commits
|
||||
// Merge the additional commits
|
||||
// TODO: They are immutable? So, it should be fine to "ovewrite" existing commits as it should be the same data, right?
|
||||
for(const commit of additional) {
|
||||
for (const commit of additional) {
|
||||
initialMap.set(commit.commitIdentifier, commit);
|
||||
}
|
||||
|
||||
@@ -224,35 +257,39 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
try {
|
||||
missingReqs = await this.engine.listMissingRequirements(this.data);
|
||||
} catch {
|
||||
return 'unknown';
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const hasMissing =
|
||||
(missingReqs.variables?.length ?? 0) > 0 ||
|
||||
(missingReqs.inputs?.length ?? 0) > 0 ||
|
||||
(missingReqs.outputs?.length ?? 0) > 0 ||
|
||||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
|
||||
(missingReqs.roles !== undefined &&
|
||||
Object.keys(missingReqs.roles).length > 0);
|
||||
|
||||
const hasSignedCommit = this.hasSignedCommitInInvitation();
|
||||
|
||||
if (!hasMissing) {
|
||||
const transactionHash = await this.deriveTransactionHash();
|
||||
if (transactionHash && await this.electrum.hasSeenTransaction(transactionHash)) {
|
||||
return 'complete';
|
||||
if (
|
||||
transactionHash &&
|
||||
(await this.electrum.hasSeenTransaction(transactionHash))
|
||||
) {
|
||||
return "complete";
|
||||
}
|
||||
}
|
||||
|
||||
if (hasInvitationExpired(this.data)) {
|
||||
return 'expired';
|
||||
return "expired";
|
||||
}
|
||||
|
||||
if (!hasMissing && hasSignedCommit) {
|
||||
return 'ready';
|
||||
return "ready";
|
||||
}
|
||||
if (hasMissing && hasSignedCommit) {
|
||||
return 'signed';
|
||||
return "signed";
|
||||
}
|
||||
return 'actionable';
|
||||
return "actionable";
|
||||
}
|
||||
|
||||
private hasSignedCommitInInvitation(): boolean {
|
||||
@@ -274,7 +311,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
*/
|
||||
private async deriveTransactionHash(): Promise<string | undefined> {
|
||||
try {
|
||||
const template = await this.engine.getTemplate(this.data.templateIdentifier);
|
||||
const template = await this.engine.getTemplate(
|
||||
this.data.templateIdentifier,
|
||||
);
|
||||
if (!template) return undefined;
|
||||
|
||||
const mergedCommit = mergeInvitationCommits(this.data, template);
|
||||
@@ -291,9 +330,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
|
||||
if (!transactionResult.success) return undefined;
|
||||
|
||||
const transactionHex = binToHex(encodeTransaction(transactionResult.transaction));
|
||||
const transactionHex = binToHex(
|
||||
encodeTransaction(transactionResult.transaction),
|
||||
);
|
||||
const rawHash: unknown = hashTransaction(hexToBin(transactionHex));
|
||||
if (typeof rawHash === 'string') return rawHash;
|
||||
if (typeof rawHash === "string") return rawHash;
|
||||
if (rawHash instanceof Uint8Array) return binToHex(rawHash);
|
||||
return undefined;
|
||||
} catch {
|
||||
@@ -307,7 +348,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
private async updateStatus(): Promise<void> {
|
||||
const status = await this.computeStatus();
|
||||
this.status = status;
|
||||
this.emit('invitation-status-changed', status);
|
||||
this.emit("invitation-status-changed", status);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,8 +433,15 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* Generate the locking bytecode for the invitation
|
||||
* TODO: Find out if this has side-effects or needs special handling
|
||||
*/
|
||||
async generateLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
||||
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
|
||||
async generateLockingBytecode(
|
||||
outputIdentifier: string,
|
||||
roleIdentifier?: string,
|
||||
): Promise<string> {
|
||||
return this.engine.generateLockingBytecode(
|
||||
this.data.templateIdentifier,
|
||||
outputIdentifier,
|
||||
roleIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
|
||||
@@ -412,9 +460,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.syncServer.publishInvitation(this.data);
|
||||
}
|
||||
|
||||
async findSuitableResources(options: Partial<FindSuitableResourcesParameters> = {}): Promise<UnspentOutputData[]> {
|
||||
async findSuitableResources(
|
||||
options: Partial<FindSuitableResourcesParameters> = {},
|
||||
): Promise<UnspentOutputData[]> {
|
||||
// Find the suitable resources
|
||||
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
|
||||
const { unspentOutputs } = await this.engine.findSuitableResources(
|
||||
this.data,
|
||||
options,
|
||||
);
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
@@ -458,8 +511,15 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* Get the locking bytecode for the invitation
|
||||
*/
|
||||
async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
||||
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
|
||||
async getLockingBytecode(
|
||||
outputIdentifier: string,
|
||||
roleIdentifier?: string,
|
||||
): Promise<string> {
|
||||
return this.engine.generateLockingBytecode(
|
||||
this.data.templateIdentifier,
|
||||
outputIdentifier,
|
||||
roleIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,33 +530,49 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// If an output identifier is provided, find all outputs with that identifier, and its valueSatoshis identifier back to the variables
|
||||
if (outputIdentifier) {
|
||||
// Get the valueSatoshis identifier from the template
|
||||
const template = await this.engine.getTemplate(this.data.templateIdentifier);
|
||||
const template = await this.engine.getTemplate(
|
||||
this.data.templateIdentifier,
|
||||
);
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`);
|
||||
throw new Error(
|
||||
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
const output = template.outputs[outputIdentifier];
|
||||
if (!output) {
|
||||
throw new Error(`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`);
|
||||
throw new Error(
|
||||
`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
const valueSatoshisIdentifier = output.valueSatoshis;
|
||||
if (!valueSatoshisIdentifier) {
|
||||
throw new Error(`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`);
|
||||
throw new Error(
|
||||
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create a list of all the variables from the commits
|
||||
const variables = this.data.commits.flatMap(c => c.data?.variables ?? []);
|
||||
const variables = this.data.commits.flatMap(
|
||||
(c) => c.data?.variables ?? [],
|
||||
);
|
||||
|
||||
// Create a dictionary of the variables
|
||||
const formattedVariables = variables.reduce((acc, v) => {
|
||||
acc[v.variableIdentifier ?? ''] = v.value;
|
||||
return acc;
|
||||
}, {} as Record<string, XOInvitationVariableValue>);
|
||||
|
||||
const formattedVariables = variables.reduce(
|
||||
(acc, v) => {
|
||||
acc[v.variableIdentifier ?? ""] = v.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, XOInvitationVariableValue>,
|
||||
);
|
||||
|
||||
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
|
||||
const valueSatoshis = await compileCashAssemblyString(String(valueSatoshisIdentifier), formattedVariables);
|
||||
|
||||
const valueSatoshis = await compileCashAssemblyString(
|
||||
String(valueSatoshisIdentifier),
|
||||
formattedVariables,
|
||||
);
|
||||
|
||||
// Return the value satoshis as a bigint
|
||||
// TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression
|
||||
return BigInt(valueSatoshis);
|
||||
@@ -505,31 +581,43 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// If we didnt get an output identifier, go through the action outputs and sum the valueSatoshis
|
||||
const action = this.data.actionIdentifier;
|
||||
if (!action) {
|
||||
throw new Error(`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`);
|
||||
throw new Error(
|
||||
`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the template
|
||||
const template = await this.engine.getTemplate(this.data.templateIdentifier);
|
||||
const template = await this.engine.getTemplate(
|
||||
this.data.templateIdentifier,
|
||||
);
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`);
|
||||
throw new Error(
|
||||
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the transaction ID from the action
|
||||
const transactionID = template.actions[action]?.transaction
|
||||
const transactionID = template.actions[action]?.transaction;
|
||||
if (!transactionID) {
|
||||
throw new Error(`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`);
|
||||
throw new Error(
|
||||
`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the transaction from the template
|
||||
const transaction = template.transactions?.[transactionID];
|
||||
if (!transaction) {
|
||||
throw new Error(`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`);
|
||||
throw new Error(
|
||||
`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Get the outputs from the transaction
|
||||
const outputs = transaction.outputs;
|
||||
if (!outputs) {
|
||||
throw new Error(`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`);
|
||||
throw new Error(
|
||||
`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create a value to store the cummulative total of the outputs
|
||||
@@ -537,7 +625,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
|
||||
// Iterate through the outputs and sum the valueSatoshis
|
||||
for (const output of outputs) {
|
||||
if (typeof output === 'string') {
|
||||
if (typeof output === "string") {
|
||||
totalSats += await this.getSatsOut(output);
|
||||
} else {
|
||||
totalSats += await this.getSatsOut(output.output);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { decodeExtendedJson, encodeExtendedJson } from '../utils/ext-json.js';
|
||||
import Database from "better-sqlite3";
|
||||
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
||||
|
||||
export class Storage {
|
||||
static async create(dbPath: string): Promise<Storage> {
|
||||
@@ -7,9 +7,13 @@ export class Storage {
|
||||
const database = new Database(dbPath);
|
||||
|
||||
// Create the storage table if it doesn't exist
|
||||
database.prepare('CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY, value TEXT)').run();
|
||||
database
|
||||
.prepare(
|
||||
"CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY, value TEXT)",
|
||||
)
|
||||
.run();
|
||||
|
||||
return new Storage(database, '');
|
||||
return new Storage(database, "");
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -39,43 +43,50 @@ export class Storage {
|
||||
|
||||
// Insert or replace the value into the database with full key (including basePath)
|
||||
const fullKey = this.getFullKey(key);
|
||||
this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue);
|
||||
this.database
|
||||
.prepare("INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)")
|
||||
.run(fullKey, encodedValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all key-value pairs from this storage namespace (shallow only - no nested children)
|
||||
*/
|
||||
async all(): Promise<{ key: string; value: any }[]> {
|
||||
let query = 'SELECT key, value FROM storage';
|
||||
let query = "SELECT key, value FROM storage";
|
||||
const params: any[] = [];
|
||||
|
||||
if (this.basePath) {
|
||||
// Filter by basePath prefix
|
||||
query += ' WHERE key LIKE ?';
|
||||
query += " WHERE key LIKE ?";
|
||||
params.push(`${this.basePath}.%`);
|
||||
}
|
||||
|
||||
// Get all the rows from the database
|
||||
const rows = await this.database.prepare(query).all(...params) as { key: string; value: any }[];
|
||||
const rows = (await this.database.prepare(query).all(...params)) as {
|
||||
key: string;
|
||||
value: any;
|
||||
}[];
|
||||
|
||||
// Filter for shallow results (only direct children)
|
||||
const filteredRows = rows.filter(row => {
|
||||
const filteredRows = rows.filter((row) => {
|
||||
const strippedKey = this.stripBasePath(row.key);
|
||||
// Only include keys that don't have additional dots (no deeper nesting)
|
||||
return !strippedKey.includes('.');
|
||||
return !strippedKey.includes(".");
|
||||
});
|
||||
|
||||
// Decode the extended json objects and strip basePath from keys
|
||||
return filteredRows.map(row => ({
|
||||
key: this.stripBasePath(row.key),
|
||||
value: decodeExtendedJson(row.value)
|
||||
return filteredRows.map((row) => ({
|
||||
key: this.stripBasePath(row.key),
|
||||
value: decodeExtendedJson(row.value),
|
||||
}));
|
||||
}
|
||||
|
||||
async get(key: string): Promise<any> {
|
||||
// Get the row from the database using full key
|
||||
const fullKey = this.getFullKey(key);
|
||||
const row = await this.database.prepare('SELECT value FROM storage WHERE key = ?').get(fullKey) as { value: any };
|
||||
const row = (await this.database
|
||||
.prepare("SELECT value FROM storage WHERE key = ?")
|
||||
.get(fullKey)) as { value: any };
|
||||
|
||||
// Return null if not found
|
||||
if (!row) return null;
|
||||
@@ -87,20 +98,22 @@ export class Storage {
|
||||
async remove(key: string): Promise<void> {
|
||||
// Delete using full key
|
||||
const fullKey = this.getFullKey(key);
|
||||
this.database.prepare('DELETE FROM storage WHERE key = ?').run(fullKey);
|
||||
this.database.prepare("DELETE FROM storage WHERE key = ?").run(fullKey);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
if (this.basePath) {
|
||||
// Clear only items under this namespace
|
||||
this.database.prepare('DELETE FROM storage WHERE key LIKE ?').run(`${this.basePath}.%`);
|
||||
this.database
|
||||
.prepare("DELETE FROM storage WHERE key LIKE ?")
|
||||
.run(`${this.basePath}.%`);
|
||||
} else {
|
||||
// Clear everything
|
||||
this.database.prepare('DELETE FROM storage').run();
|
||||
this.database.prepare("DELETE FROM storage").run();
|
||||
}
|
||||
}
|
||||
|
||||
child(key: string): Storage {
|
||||
return new Storage(this.database, this.getFullKey(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
* Export all shared components.
|
||||
*/
|
||||
|
||||
export { Screen } from './Screen.js';
|
||||
export { Input, TextDisplay } from './Input.js';
|
||||
export { Button, ButtonRow } from './Button.js';
|
||||
export {
|
||||
List,
|
||||
SimpleList,
|
||||
export { Screen } from "./Screen.js";
|
||||
export { Input, TextDisplay } from "./Input.js";
|
||||
export { Button, ButtonRow } from "./Button.js";
|
||||
export {
|
||||
List,
|
||||
SimpleList,
|
||||
ScrollableList,
|
||||
type ListItem,
|
||||
type ListItemData,
|
||||
type ListGroup,
|
||||
type ScrollableListProps,
|
||||
} from './List.js';
|
||||
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
|
||||
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
|
||||
export { QRCode } from './QRCode.js';
|
||||
} from "./List.js";
|
||||
export { InputDialog, ConfirmDialog, MessageDialog } from "./Dialog.js";
|
||||
export {
|
||||
ProgressBar,
|
||||
StepIndicator,
|
||||
Loading,
|
||||
type Step,
|
||||
} from "./ProgressBar.js";
|
||||
export { QRCode } from "./QRCode.js";
|
||||
|
||||
@@ -2,19 +2,24 @@
|
||||
* Export all hooks.
|
||||
*/
|
||||
|
||||
export { NavigationProvider, useNavigation } from './useNavigation.js';
|
||||
export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js';
|
||||
export { NavigationProvider, useNavigation } from "./useNavigation.js";
|
||||
export {
|
||||
AppProvider,
|
||||
useAppContext,
|
||||
useDialog,
|
||||
useStatus,
|
||||
} from "./useAppContext.js";
|
||||
export {
|
||||
useInvitations,
|
||||
useInvitation,
|
||||
useInvitationData,
|
||||
useCreateInvitation,
|
||||
useInvitationIds,
|
||||
} from './useInvitations.js';
|
||||
} from "./useInvitations.js";
|
||||
export {
|
||||
InputLayerProvider,
|
||||
useInputLayer,
|
||||
useLayeredInput,
|
||||
useBlockableInput,
|
||||
useIsInputCaptured,
|
||||
} from './useInputLayer.js';
|
||||
} from "./useInputLayer.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FlowContext, StepType } from '../types.js';
|
||||
import { WizardFlow } from './WizardFlow.js';
|
||||
import type { FlowContext, StepType } from "../types.js";
|
||||
import { WizardFlow } from "./WizardFlow.js";
|
||||
|
||||
/**
|
||||
* Flow strategy for data-only actions (e.g. sign, verify).
|
||||
@@ -12,7 +12,7 @@ import { WizardFlow } from './WizardFlow.js';
|
||||
* The result step is currently stubbed.
|
||||
*/
|
||||
export class DataWizardFlow extends WizardFlow {
|
||||
readonly type = 'data' as const;
|
||||
readonly type = "data" as const;
|
||||
|
||||
/** The data field identifiers this action produces (from action.data). */
|
||||
readonly dataOutputs: string[];
|
||||
@@ -24,9 +24,9 @@ export class DataWizardFlow extends WizardFlow {
|
||||
|
||||
getStepTypes(context: FlowContext): StepType[] {
|
||||
const steps: StepType[] = [];
|
||||
if (context.availableRoles.length > 1) steps.push('role-select');
|
||||
if (context.hasVariables) steps.push('variables');
|
||||
steps.push('result');
|
||||
if (context.availableRoles.length > 1) steps.push("role-select");
|
||||
if (context.hasVariables) steps.push("variables");
|
||||
steps.push("result");
|
||||
return steps;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,6 @@ export class DataWizardFlow extends WizardFlow {
|
||||
}
|
||||
|
||||
getFinalActionLabel(): string {
|
||||
return 'Done';
|
||||
return "Done";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FlowContext, StepType } from '../types.js';
|
||||
import { WizardFlow } from './WizardFlow.js';
|
||||
import type { FlowContext, StepType } from "../types.js";
|
||||
import { WizardFlow } from "./WizardFlow.js";
|
||||
|
||||
/**
|
||||
* Flow strategy for transaction-based actions.
|
||||
@@ -10,15 +10,15 @@ import { WizardFlow } from './WizardFlow.js';
|
||||
* another party to complete.
|
||||
*/
|
||||
export class TransactionWizardFlow extends WizardFlow {
|
||||
readonly type = 'transaction' as const;
|
||||
readonly type = "transaction" as const;
|
||||
|
||||
getStepTypes(context: FlowContext): StepType[] {
|
||||
const steps: StepType[] = [];
|
||||
if (context.availableRoles.length > 1) steps.push('role-select');
|
||||
if (context.hasVariables) steps.push('variables');
|
||||
if (context.shouldCollectInputs) steps.push('inputs');
|
||||
steps.push('review');
|
||||
steps.push('publish');
|
||||
if (context.availableRoles.length > 1) steps.push("role-select");
|
||||
if (context.hasVariables) steps.push("variables");
|
||||
if (context.shouldCollectInputs) steps.push("inputs");
|
||||
steps.push("review");
|
||||
steps.push("publish");
|
||||
return steps;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,6 @@ export class TransactionWizardFlow extends WizardFlow {
|
||||
}
|
||||
|
||||
getFinalActionLabel(context: FlowContext): string {
|
||||
return this.canFinalize(context) ? 'Sign & Broadcast' : 'Done';
|
||||
return this.canFinalize(context) ? "Sign & Broadcast" : "Done";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FlowContext, StepType } from '../types.js';
|
||||
import type { FlowContext, StepType } from "../types.js";
|
||||
|
||||
/**
|
||||
* Abstract strategy that defines the shape of a wizard flow.
|
||||
@@ -9,7 +9,7 @@ import type { FlowContext, StepType } from '../types.js';
|
||||
* produced from these methods.
|
||||
*/
|
||||
export abstract class WizardFlow {
|
||||
abstract readonly type: 'transaction' | 'data';
|
||||
abstract readonly type: "transaction" | "data";
|
||||
|
||||
/** Determine which step types this flow needs given the current context. */
|
||||
abstract getStepTypes(context: FlowContext): StepType[];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { XOTemplateAction } from '@xo-cash/types';
|
||||
import { TransactionWizardFlow } from './TransactionWizardFlow.js';
|
||||
import { DataWizardFlow } from './DataWizardFlow.js';
|
||||
import type { WizardFlow } from './WizardFlow.js';
|
||||
import type { XOTemplateAction } from "@xo-cash/types";
|
||||
import { TransactionWizardFlow } from "./TransactionWizardFlow.js";
|
||||
import { DataWizardFlow } from "./DataWizardFlow.js";
|
||||
import type { WizardFlow } from "./WizardFlow.js";
|
||||
|
||||
export { WizardFlow } from './WizardFlow.js';
|
||||
export { TransactionWizardFlow } from './TransactionWizardFlow.js';
|
||||
export { DataWizardFlow } from './DataWizardFlow.js';
|
||||
export { WizardFlow } from "./WizardFlow.js";
|
||||
export { TransactionWizardFlow } from "./TransactionWizardFlow.js";
|
||||
export { DataWizardFlow } from "./DataWizardFlow.js";
|
||||
|
||||
/**
|
||||
* Inspect a template action and return the appropriate wizard flow strategy.
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useNavigation } from '../../../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../../../hooks/useAppContext.js';
|
||||
import { copyToClipboard } from '../../../utils/clipboard.js';
|
||||
import { roleRequiresInputs } from '../../../../utils/invitation-flow.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import type { StepConfig, FlowContext, DataResult } from '../types.js';
|
||||
import { createWizardFlow, type WizardFlow, DataWizardFlow } from '../flows/index.js';
|
||||
import { useRoleSelection } from './useRoleSelection.js';
|
||||
import { useVariableInputs } from './useVariableInputs.js';
|
||||
import { useUtxoSelection } from './useUtxoSelection.js';
|
||||
import { useInvitationManager } from './useInvitationManager.js';
|
||||
import { useWizardFocus } from './useWizardFocus.js';
|
||||
import { useWizardSteps } from './useWizardSteps.js';
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useNavigation } from "../../../hooks/useNavigation.js";
|
||||
import { useAppContext, useStatus } from "../../../hooks/useAppContext.js";
|
||||
import { copyToClipboard } from "../../../utils/clipboard.js";
|
||||
import { roleRequiresInputs } from "../../../../utils/invitation-flow.js";
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
import type { StepConfig, FlowContext, DataResult } from "../types.js";
|
||||
import {
|
||||
createWizardFlow,
|
||||
type WizardFlow,
|
||||
DataWizardFlow,
|
||||
} from "../flows/index.js";
|
||||
import { useRoleSelection } from "./useRoleSelection.js";
|
||||
import { useVariableInputs } from "./useVariableInputs.js";
|
||||
import { useUtxoSelection } from "./useUtxoSelection.js";
|
||||
import { useInvitationManager } from "./useInvitationManager.js";
|
||||
import { useWizardFocus } from "./useWizardFocus.js";
|
||||
import { useWizardSteps } from "./useWizardSteps.js";
|
||||
|
||||
/**
|
||||
* Thin orchestrator that composes domain hooks and wires them
|
||||
@@ -25,7 +29,7 @@ export function useActionWizard() {
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
if (!appService) {
|
||||
throw new Error('AppService not initialized');
|
||||
throw new Error("AppService not initialized");
|
||||
}
|
||||
|
||||
// ── Navigation data ───────────────────────────────────────────
|
||||
@@ -35,14 +39,14 @@ export function useActionWizard() {
|
||||
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
|
||||
|
||||
// ── Derived template data ─────────────────────────────────────
|
||||
const action = template?.actions?.[actionIdentifier ?? ''];
|
||||
const actionName = action?.name || actionIdentifier || 'Unknown';
|
||||
const action = template?.actions?.[actionIdentifier ?? ""];
|
||||
const actionName = action?.name || actionIdentifier || "Unknown";
|
||||
|
||||
// ── Flow strategy ─────────────────────────────────────────────
|
||||
const flow = useMemo<WizardFlow>(() => {
|
||||
// Create a default action if no action is found
|
||||
if (!action) {
|
||||
return createWizardFlow({ name: '', description: '' });
|
||||
return createWizardFlow({ name: "", description: "" });
|
||||
}
|
||||
|
||||
// Create the flow from the action
|
||||
@@ -50,10 +54,19 @@ export function useActionWizard() {
|
||||
}, [action]);
|
||||
|
||||
// ── Domain hooks ──────────────────────────────────────────────
|
||||
const roleSelection = useRoleSelection(template, actionIdentifier, actionRolesFromNavigation);
|
||||
const roleSelection = useRoleSelection(
|
||||
template,
|
||||
actionIdentifier,
|
||||
actionRolesFromNavigation,
|
||||
);
|
||||
const variableInputs = useVariableInputs();
|
||||
const utxoSelection = useUtxoSelection();
|
||||
const invitationManager = useInvitationManager({ appService, showError, showInfo, setStatus });
|
||||
const invitationManager = useInvitationManager({
|
||||
appService,
|
||||
showError,
|
||||
showInfo,
|
||||
setStatus,
|
||||
});
|
||||
const focus = useWizardFocus();
|
||||
|
||||
// ── Data results (data-only flows) ────────────────────────────
|
||||
@@ -66,37 +79,57 @@ export function useActionWizard() {
|
||||
const role = act?.roles?.[roleSelection.effectiveRole];
|
||||
const varIds = role?.requirements?.variables;
|
||||
if (varIds && varIds.length > 0) {
|
||||
variableInputs.initFromTemplate(template, actionIdentifier, roleSelection.effectiveRole);
|
||||
variableInputs.initFromTemplate(
|
||||
template,
|
||||
actionIdentifier,
|
||||
roleSelection.effectiveRole,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [template, actionIdentifier, roleSelection.effectiveRole, variableInputs.initFromTemplate]);
|
||||
}, [
|
||||
template,
|
||||
actionIdentifier,
|
||||
roleSelection.effectiveRole,
|
||||
variableInputs.initFromTemplate,
|
||||
]);
|
||||
|
||||
// ── Determine whether creator should provide inputs ───────────
|
||||
const shouldCollectInputs = useMemo(() => {
|
||||
if (flow.type !== 'transaction') return false;
|
||||
if (!template || !actionIdentifier || !roleSelection.effectiveRole) return false;
|
||||
if (flow.type !== "transaction") return false;
|
||||
if (!template || !actionIdentifier || !roleSelection.effectiveRole)
|
||||
return false;
|
||||
|
||||
const act = template.actions?.[actionIdentifier];
|
||||
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
|
||||
const isSingleRoleAction = totalActionRoles <= 1;
|
||||
return isSingleRoleAction && roleRequiresInputs(template, actionIdentifier, roleSelection.effectiveRole);
|
||||
return (
|
||||
isSingleRoleAction &&
|
||||
roleRequiresInputs(
|
||||
template,
|
||||
actionIdentifier,
|
||||
roleSelection.effectiveRole,
|
||||
)
|
||||
);
|
||||
}, [flow.type, template, actionIdentifier, roleSelection.effectiveRole]);
|
||||
|
||||
// ── Build flow context for strategy methods ───────────────────
|
||||
const flowContext = useMemo<FlowContext>(() => ({
|
||||
availableRoles: roleSelection.availableRoles,
|
||||
hasVariables: variableInputs.variables.length > 0,
|
||||
shouldCollectInputs,
|
||||
requirementsComplete: invitationManager.requirementsComplete,
|
||||
wizardCollectedInputs: shouldCollectInputs,
|
||||
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
|
||||
}), [
|
||||
roleSelection.availableRoles,
|
||||
variableInputs.variables.length,
|
||||
shouldCollectInputs,
|
||||
invitationManager.requirementsComplete,
|
||||
invitationManager.hasSignedAndBroadcasted,
|
||||
]);
|
||||
const flowContext = useMemo<FlowContext>(
|
||||
() => ({
|
||||
availableRoles: roleSelection.availableRoles,
|
||||
hasVariables: variableInputs.variables.length > 0,
|
||||
shouldCollectInputs,
|
||||
requirementsComplete: invitationManager.requirementsComplete,
|
||||
wizardCollectedInputs: shouldCollectInputs,
|
||||
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
|
||||
}),
|
||||
[
|
||||
roleSelection.availableRoles,
|
||||
variableInputs.variables.length,
|
||||
shouldCollectInputs,
|
||||
invitationManager.requirementsComplete,
|
||||
invitationManager.hasSignedAndBroadcasted,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Handle Enter inside a TextInput ───────────────────────────
|
||||
const handleTextInputSubmit = useCallback(() => {
|
||||
@@ -105,7 +138,12 @@ export function useActionWizard() {
|
||||
} else {
|
||||
focus.moveToButtons();
|
||||
}
|
||||
}, [focus.focusedInput, variableInputs.variables.length, focus.setFocusedInput, focus.moveToButtons]);
|
||||
}, [
|
||||
focus.focusedInput,
|
||||
variableInputs.variables.length,
|
||||
focus.setFocusedInput,
|
||||
focus.moveToButtons,
|
||||
]);
|
||||
|
||||
// ── Copy invitation ID to clipboard ───────────────────────────
|
||||
const copyId = useCallback(async () => {
|
||||
@@ -121,38 +159,61 @@ export function useActionWizard() {
|
||||
}, [invitationManager.invitationId, showInfo, showError]);
|
||||
|
||||
// ── Helper: create invitation if it doesn't exist yet ─────────
|
||||
const ensureInvitation = useCallback(async (roleId?: string): Promise<string | null> => {
|
||||
if (invitationManager.invitationId) return invitationManager.invitationId;
|
||||
const role = roleId ?? roleSelection.effectiveRole;
|
||||
if (!templateIdentifier || !actionIdentifier || !role || !template) return null;
|
||||
return invitationManager.createWithVariables(
|
||||
templateIdentifier, actionIdentifier, role, template, variableInputs.variables,
|
||||
);
|
||||
}, [
|
||||
invitationManager.invitationId,
|
||||
invitationManager.createWithVariables,
|
||||
roleSelection.effectiveRole,
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
template,
|
||||
variableInputs.variables,
|
||||
]);
|
||||
const ensureInvitation = useCallback(
|
||||
async (roleId?: string): Promise<string | null> => {
|
||||
if (invitationManager.invitationId) return invitationManager.invitationId;
|
||||
const role = roleId ?? roleSelection.effectiveRole;
|
||||
if (!templateIdentifier || !actionIdentifier || !role || !template)
|
||||
return null;
|
||||
return invitationManager.createWithVariables(
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
role,
|
||||
template,
|
||||
variableInputs.variables,
|
||||
);
|
||||
},
|
||||
[
|
||||
invitationManager.invitationId,
|
||||
invitationManager.createWithVariables,
|
||||
roleSelection.effectiveRole,
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
template,
|
||||
variableInputs.variables,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Helper: load UTXOs after invitation is created ────────────
|
||||
const loadUtxosForInvitation = useCallback(async (invId: string) => {
|
||||
if (!appService || !templateIdentifier) return;
|
||||
const instance = appService.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === invId,
|
||||
);
|
||||
if (instance) {
|
||||
invitationManager.setIsProcessing(true);
|
||||
try {
|
||||
await utxoSelection.loadUtxos(instance, templateIdentifier, variableInputs.variables, setStatus);
|
||||
} finally {
|
||||
invitationManager.setIsProcessing(false);
|
||||
const loadUtxosForInvitation = useCallback(
|
||||
async (invId: string) => {
|
||||
if (!appService || !templateIdentifier) return;
|
||||
const instance = appService.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === invId,
|
||||
);
|
||||
if (instance) {
|
||||
invitationManager.setIsProcessing(true);
|
||||
try {
|
||||
await utxoSelection.loadUtxos(
|
||||
instance,
|
||||
templateIdentifier,
|
||||
variableInputs.variables,
|
||||
setStatus,
|
||||
);
|
||||
} finally {
|
||||
invitationManager.setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [appService, templateIdentifier, variableInputs.variables, utxoSelection.loadUtxos, invitationManager.setIsProcessing, setStatus]);
|
||||
},
|
||||
[
|
||||
appService,
|
||||
templateIdentifier,
|
||||
variableInputs.variables,
|
||||
utxoSelection.loadUtxos,
|
||||
invitationManager.setIsProcessing,
|
||||
setStatus,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Build step configs from flow strategy ─────────────────────
|
||||
const stepConfigs = useMemo<StepConfig[]>(() => {
|
||||
@@ -160,39 +221,56 @@ export function useActionWizard() {
|
||||
|
||||
return stepTypes.map((type): StepConfig => {
|
||||
switch (type) {
|
||||
case 'role-select':
|
||||
case "role-select":
|
||||
return {
|
||||
type,
|
||||
name: 'Select Role',
|
||||
name: "Select Role",
|
||||
validate: () => {
|
||||
const selectedRole = roleSelection.availableRoles[roleSelection.selectedRoleIndex];
|
||||
return selectedRole ? null : 'Please select a role';
|
||||
const selectedRole =
|
||||
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
|
||||
return selectedRole ? null : "Please select a role";
|
||||
},
|
||||
onNext: async () => {
|
||||
const selectedRole = roleSelection.availableRoles[roleSelection.selectedRoleIndex];
|
||||
const selectedRole =
|
||||
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
|
||||
if (!selectedRole) return false;
|
||||
|
||||
// Initialize variables for this role immediately
|
||||
if (template && actionIdentifier) {
|
||||
const act = template.actions?.[actionIdentifier];
|
||||
const role = act?.roles?.[selectedRole];
|
||||
const hasVars = (role?.requirements?.variables?.length ?? 0) > 0;
|
||||
const hasVars =
|
||||
(role?.requirements?.variables?.length ?? 0) > 0;
|
||||
|
||||
if (hasVars) {
|
||||
variableInputs.initFromTemplate(template, actionIdentifier, selectedRole);
|
||||
variableInputs.initFromTemplate(
|
||||
template,
|
||||
actionIdentifier,
|
||||
selectedRole,
|
||||
);
|
||||
}
|
||||
|
||||
// If no variables step follows, create the invitation now (transaction flows only)
|
||||
if (!hasVars && flow.type === 'transaction') {
|
||||
if (!hasVars && flow.type === "transaction") {
|
||||
if (templateIdentifier && template) {
|
||||
const invId = await invitationManager.createWithVariables(
|
||||
templateIdentifier, actionIdentifier, selectedRole, template, [],
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
selectedRole,
|
||||
template,
|
||||
[],
|
||||
);
|
||||
if (!invId) return false;
|
||||
|
||||
// Pre-load UTXOs if the inputs step follows
|
||||
const totalRoles = Object.keys(act?.roles ?? {}).length;
|
||||
const needsInputs = totalRoles <= 1 && roleRequiresInputs(template, actionIdentifier, selectedRole);
|
||||
const needsInputs =
|
||||
totalRoles <= 1 &&
|
||||
roleRequiresInputs(
|
||||
template,
|
||||
actionIdentifier,
|
||||
selectedRole,
|
||||
);
|
||||
if (needsInputs) {
|
||||
await loadUtxosForInvitation(invId);
|
||||
}
|
||||
@@ -206,18 +284,27 @@ export function useActionWizard() {
|
||||
},
|
||||
};
|
||||
|
||||
case 'variables':
|
||||
case "variables":
|
||||
return {
|
||||
type,
|
||||
name: 'Variables',
|
||||
name: "Variables",
|
||||
validate: () => variableInputs.validate(),
|
||||
onNext: async () => {
|
||||
if (flow.type === 'transaction') {
|
||||
if (!templateIdentifier || !actionIdentifier || !template || !roleSelection.effectiveRole) return false;
|
||||
if (flow.type === "transaction") {
|
||||
if (
|
||||
!templateIdentifier ||
|
||||
!actionIdentifier ||
|
||||
!template ||
|
||||
!roleSelection.effectiveRole
|
||||
)
|
||||
return false;
|
||||
|
||||
const invId = await invitationManager.createWithVariables(
|
||||
templateIdentifier, actionIdentifier, roleSelection.effectiveRole,
|
||||
template, variableInputs.variables,
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
roleSelection.effectiveRole,
|
||||
template,
|
||||
variableInputs.variables,
|
||||
);
|
||||
if (!invId) return false;
|
||||
|
||||
@@ -233,23 +320,28 @@ export function useActionWizard() {
|
||||
},
|
||||
};
|
||||
|
||||
case 'inputs':
|
||||
case "inputs":
|
||||
return {
|
||||
type,
|
||||
name: 'Select UTXOs',
|
||||
name: "Select UTXOs",
|
||||
validate: () => utxoSelection.validate(),
|
||||
onNext: async () => {
|
||||
const selectedUtxos = utxoSelection.availableUtxos.filter((u) => u.selected);
|
||||
const success = await invitationManager.addInputsAndOutputs(selectedUtxos, utxoSelection.changeAmount);
|
||||
const selectedUtxos = utxoSelection.availableUtxos.filter(
|
||||
(u) => u.selected,
|
||||
);
|
||||
const success = await invitationManager.addInputsAndOutputs(
|
||||
selectedUtxos,
|
||||
utxoSelection.changeAmount,
|
||||
);
|
||||
if (success) focus.resetToContent();
|
||||
return success;
|
||||
},
|
||||
};
|
||||
|
||||
case 'review':
|
||||
case "review":
|
||||
return {
|
||||
type,
|
||||
name: 'Review',
|
||||
name: "Review",
|
||||
validate: () => null,
|
||||
onNext: async () => {
|
||||
// Ensure invitation exists (covers the case where no prior step created it)
|
||||
@@ -261,10 +353,10 @@ export function useActionWizard() {
|
||||
},
|
||||
};
|
||||
|
||||
case 'publish':
|
||||
case "publish":
|
||||
return {
|
||||
type,
|
||||
name: 'Publish',
|
||||
name: "Publish",
|
||||
validate: () => null,
|
||||
onNext: async () => {
|
||||
if (flow.canFinalize(flowContext)) {
|
||||
@@ -277,10 +369,10 @@ export function useActionWizard() {
|
||||
},
|
||||
};
|
||||
|
||||
case 'result':
|
||||
case "result":
|
||||
return {
|
||||
type,
|
||||
name: 'Result',
|
||||
name: "Result",
|
||||
validate: () => null,
|
||||
onNext: async () => {
|
||||
// Data-only flows: populate stubbed results, then exit
|
||||
@@ -290,7 +382,7 @@ export function useActionWizard() {
|
||||
return {
|
||||
id: dataId,
|
||||
name: dataDef?.hint ?? dataId,
|
||||
type: dataDef?.type ?? 'unknown',
|
||||
type: dataDef?.type ?? "unknown",
|
||||
hint: dataDef?.hint,
|
||||
value: null, // Engine-level data execution not yet implemented
|
||||
};
|
||||
@@ -303,13 +395,30 @@ export function useActionWizard() {
|
||||
};
|
||||
|
||||
default:
|
||||
return { type, name: type, validate: () => null, onNext: async () => true };
|
||||
return {
|
||||
type,
|
||||
name: type,
|
||||
validate: () => null,
|
||||
onNext: async () => true,
|
||||
};
|
||||
}
|
||||
});
|
||||
}, [
|
||||
flow, flowContext, roleSelection, variableInputs, utxoSelection,
|
||||
invitationManager, focus, template, templateIdentifier, actionIdentifier,
|
||||
shouldCollectInputs, ensureInvitation, loadUtxosForInvitation, goBack, setStatus,
|
||||
flow,
|
||||
flowContext,
|
||||
roleSelection,
|
||||
variableInputs,
|
||||
utxoSelection,
|
||||
invitationManager,
|
||||
focus,
|
||||
template,
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
shouldCollectInputs,
|
||||
ensureInvitation,
|
||||
loadUtxosForInvitation,
|
||||
goBack,
|
||||
setStatus,
|
||||
]);
|
||||
|
||||
// ── Step navigation ───────────────────────────────────────────
|
||||
@@ -318,7 +427,7 @@ export function useActionWizard() {
|
||||
// ── Set initial status ────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!template || !actionIdentifier) {
|
||||
showError('Missing wizard data');
|
||||
showError("Missing wizard data");
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
@@ -327,22 +436,30 @@ export function useActionWizard() {
|
||||
? `${actionIdentifier}/${roleSelection.effectiveRole}`
|
||||
: actionIdentifier,
|
||||
);
|
||||
}, [template, actionIdentifier, roleSelection.effectiveRole, showError, goBack, setStatus]);
|
||||
}, [
|
||||
template,
|
||||
actionIdentifier,
|
||||
roleSelection.effectiveRole,
|
||||
showError,
|
||||
goBack,
|
||||
setStatus,
|
||||
]);
|
||||
|
||||
// ── Convenience derived values ────────────────────────────────
|
||||
const textInputHasFocus =
|
||||
stepper.currentStepData?.type === 'variables' && focus.focusArea === 'content';
|
||||
stepper.currentStepData?.type === "variables" &&
|
||||
focus.focusArea === "content";
|
||||
|
||||
const canSignAndBroadcast = flow.canFinalize(flowContext);
|
||||
|
||||
const isLastStep = stepper.currentStep >= stepper.steps.length - 1;
|
||||
const lastStepType = stepper.currentStepData?.type;
|
||||
const nextButtonLabel =
|
||||
lastStepType === 'publish'
|
||||
lastStepType === "publish"
|
||||
? flow.getFinalActionLabel(flowContext)
|
||||
: lastStepType === 'result'
|
||||
? 'Done'
|
||||
: 'Next';
|
||||
: lastStepType === "result"
|
||||
? "Done"
|
||||
: "Next";
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
|
||||
import type { VariableInput, SelectableUTXO } from '../types.js';
|
||||
import { useState, useCallback } from "react";
|
||||
import type {
|
||||
XOTemplate,
|
||||
XOInvitation,
|
||||
XOTemplateTransactionOutput,
|
||||
} from "@xo-cash/types";
|
||||
import type { VariableInput, SelectableUTXO } from "../types.js";
|
||||
import {
|
||||
getTransactionOutputIdentifier,
|
||||
isInvitationRequirementsComplete,
|
||||
resolveProvidedLockingBytecodeHex,
|
||||
} from '../../../../utils/invitation-flow.js';
|
||||
import type { AppService } from '../../../../services/app.js';
|
||||
} from "../../../../utils/invitation-flow.js";
|
||||
import type { AppService } from "../../../../services/app.js";
|
||||
|
||||
interface InvitationManagerDeps {
|
||||
appService: AppService;
|
||||
@@ -32,26 +36,27 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
/** Re-check whether all invitation requirements are satisfied. */
|
||||
const refreshRequirements = useCallback(async (
|
||||
identifier: string | null = invitationId,
|
||||
): Promise<boolean> => {
|
||||
if (!identifier || !appService) {
|
||||
setRequirementsComplete(false);
|
||||
return false;
|
||||
}
|
||||
const refreshRequirements = useCallback(
|
||||
async (identifier: string | null = invitationId): Promise<boolean> => {
|
||||
if (!identifier || !appService) {
|
||||
setRequirementsComplete(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const instance = appService.invitations.find(
|
||||
(inv: any) => inv.data.invitationIdentifier === identifier,
|
||||
);
|
||||
if (!instance) {
|
||||
setRequirementsComplete(false);
|
||||
return false;
|
||||
}
|
||||
const instance = appService.invitations.find(
|
||||
(inv: any) => inv.data.invitationIdentifier === identifier,
|
||||
);
|
||||
if (!instance) {
|
||||
setRequirementsComplete(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const complete = await isInvitationRequirementsComplete(instance);
|
||||
setRequirementsComplete(complete);
|
||||
return complete;
|
||||
}, [appService, invitationId]);
|
||||
const complete = await isInvitationRequirementsComplete(instance);
|
||||
setRequirementsComplete(complete);
|
||||
return complete;
|
||||
},
|
||||
[appService, invitationId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Create an invitation, persist variable values, and add
|
||||
@@ -59,177 +64,201 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
|
||||
*
|
||||
* @returns The invitation identifier on success, or null on failure.
|
||||
*/
|
||||
const createWithVariables = useCallback(async (
|
||||
templateIdentifier: string,
|
||||
actionIdentifier: string,
|
||||
roleIdentifier: string,
|
||||
template: XOTemplate,
|
||||
variables: VariableInput[],
|
||||
): Promise<string | null> => {
|
||||
if (!appService) return null;
|
||||
const createWithVariables = useCallback(
|
||||
async (
|
||||
templateIdentifier: string,
|
||||
actionIdentifier: string,
|
||||
roleIdentifier: string,
|
||||
template: XOTemplate,
|
||||
variables: VariableInput[],
|
||||
): Promise<string | null> => {
|
||||
if (!appService) return null;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Creating invitation...');
|
||||
setIsProcessing(true);
|
||||
setStatus("Creating invitation...");
|
||||
|
||||
try {
|
||||
// Create via the engine
|
||||
const xoInvitation = await appService.engine.createInvitation({
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
});
|
||||
|
||||
// Wrap and track
|
||||
const invitationInstance = await appService.createInvitation(xoInvitation);
|
||||
let inv = invitationInstance.data;
|
||||
const invId = inv.invitationIdentifier;
|
||||
setInvitationId(invId);
|
||||
|
||||
// Persist variable values
|
||||
if (variables.length > 0) {
|
||||
setStatus('Adding variables...');
|
||||
const variableData = variables.map((v) => {
|
||||
const isNumeric =
|
||||
['integer', 'number', 'satoshis'].includes(v.type) ||
|
||||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
|
||||
|
||||
return {
|
||||
variableIdentifier: v.id,
|
||||
roleIdentifier,
|
||||
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
||||
};
|
||||
try {
|
||||
// Create via the engine
|
||||
const xoInvitation = await appService.engine.createInvitation({
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
});
|
||||
await invitationInstance.addVariables(variableData);
|
||||
inv = invitationInstance.data;
|
||||
}
|
||||
|
||||
// Build variable values lookup for output resolution
|
||||
const variableValuesByIdentifier = variables.reduce((acc, variable) => {
|
||||
if (typeof variable.value === 'string' && variable.value.trim().length > 0) {
|
||||
acc[variable.id] = variable.value;
|
||||
// Wrap and track
|
||||
const invitationInstance =
|
||||
await appService.createInvitation(xoInvitation);
|
||||
let inv = invitationInstance.data;
|
||||
const invId = inv.invitationIdentifier;
|
||||
setInvitationId(invId);
|
||||
|
||||
// Persist variable values
|
||||
if (variables.length > 0) {
|
||||
setStatus("Adding variables...");
|
||||
const variableData = variables.map((v) => {
|
||||
const isNumeric =
|
||||
["integer", "number", "satoshis"].includes(v.type) ||
|
||||
(v.hint && ["satoshis", "amount"].includes(v.hint));
|
||||
|
||||
return {
|
||||
variableIdentifier: v.id,
|
||||
roleIdentifier,
|
||||
value: isNumeric ? BigInt(v.value || "0") : v.value,
|
||||
};
|
||||
});
|
||||
await invitationInstance.addVariables(variableData);
|
||||
inv = invitationInstance.data;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
// Add template-required transaction outputs
|
||||
const act = template.actions?.[actionIdentifier];
|
||||
const transaction = act?.transaction
|
||||
? template.transactions?.[act.transaction]
|
||||
: null;
|
||||
|
||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||
setStatus('Adding required outputs...');
|
||||
const outputsToAdd = await Promise.all(
|
||||
transaction.outputs.map(async (output: XOTemplateTransactionOutput) => {
|
||||
const outputIdentifier = getTransactionOutputIdentifier(output);
|
||||
if (!outputIdentifier) {
|
||||
throw new Error('Invalid transaction output definition');
|
||||
// Build variable values lookup for output resolution
|
||||
const variableValuesByIdentifier = variables.reduce(
|
||||
(acc, variable) => {
|
||||
if (
|
||||
typeof variable.value === "string" &&
|
||||
variable.value.trim().length > 0
|
||||
) {
|
||||
acc[variable.id] = variable.value;
|
||||
}
|
||||
|
||||
const providedHex = resolveProvidedLockingBytecodeHex(
|
||||
template,
|
||||
outputIdentifier,
|
||||
variableValuesByIdentifier,
|
||||
);
|
||||
|
||||
const lockingBytecodeHex =
|
||||
providedHex ?? await invitationInstance.generateLockingBytecode(outputIdentifier, roleIdentifier);
|
||||
|
||||
return { outputIdentifier, lockingBytecode: lockingBytecodeHex };
|
||||
}),
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOutputs accept a hex string. 3. Have addOutputs handle lockscript generation.
|
||||
await invitationInstance.addOutputs(
|
||||
outputsToAdd.map((output) => ({
|
||||
outputIdentifier: output.outputIdentifier,
|
||||
lockingBytecode: new Uint8Array(Buffer.from(output.lockingBytecode, 'hex')),
|
||||
})),
|
||||
);
|
||||
// Add template-required transaction outputs
|
||||
const act = template.actions?.[actionIdentifier];
|
||||
const transaction = act?.transaction
|
||||
? template.transactions?.[act.transaction]
|
||||
: null;
|
||||
|
||||
inv = invitationInstance.data;
|
||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||
setStatus("Adding required outputs...");
|
||||
const outputsToAdd = await Promise.all(
|
||||
transaction.outputs.map(
|
||||
async (output: XOTemplateTransactionOutput) => {
|
||||
const outputIdentifier = getTransactionOutputIdentifier(output);
|
||||
if (!outputIdentifier) {
|
||||
throw new Error("Invalid transaction output definition");
|
||||
}
|
||||
|
||||
const providedHex = resolveProvidedLockingBytecodeHex(
|
||||
template,
|
||||
outputIdentifier,
|
||||
variableValuesByIdentifier,
|
||||
);
|
||||
|
||||
const lockingBytecodeHex =
|
||||
providedHex ??
|
||||
(await invitationInstance.generateLockingBytecode(
|
||||
outputIdentifier,
|
||||
roleIdentifier,
|
||||
));
|
||||
|
||||
return {
|
||||
outputIdentifier,
|
||||
lockingBytecode: lockingBytecodeHex,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOutputs accept a hex string. 3. Have addOutputs handle lockscript generation.
|
||||
await invitationInstance.addOutputs(
|
||||
outputsToAdd.map((output) => ({
|
||||
outputIdentifier: output.outputIdentifier,
|
||||
lockingBytecode: new Uint8Array(
|
||||
Buffer.from(output.lockingBytecode, "hex"),
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
inv = invitationInstance.data;
|
||||
}
|
||||
|
||||
setInvitation(inv);
|
||||
await refreshRequirements(invId);
|
||||
setStatus("Invitation created");
|
||||
return invId;
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
|
||||
setInvitation(inv);
|
||||
await refreshRequirements(invId);
|
||||
setStatus('Invitation created');
|
||||
return invId;
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [appService, showError, setStatus, refreshRequirements]);
|
||||
},
|
||||
[appService, showError, setStatus, refreshRequirements],
|
||||
);
|
||||
|
||||
/**
|
||||
* Add the selected UTXOs as inputs and a change output to the invitation.
|
||||
*
|
||||
* @returns true on success, false on failure.
|
||||
*/
|
||||
const addInputsAndOutputs = useCallback(async (
|
||||
selectedUtxos: SelectableUTXO[],
|
||||
changeAmount: bigint,
|
||||
): Promise<boolean> => {
|
||||
if (!invitationId || !appService) return false;
|
||||
const addInputsAndOutputs = useCallback(
|
||||
async (
|
||||
selectedUtxos: SelectableUTXO[],
|
||||
changeAmount: bigint,
|
||||
): Promise<boolean> => {
|
||||
if (!invitationId || !appService) return false;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Adding inputs and outputs...');
|
||||
setIsProcessing(true);
|
||||
setStatus("Adding inputs and outputs...");
|
||||
|
||||
try {
|
||||
const instance = appService.invitations.find(
|
||||
(inv: any) => inv.data.invitationIdentifier === invitationId,
|
||||
);
|
||||
if (!instance) throw new Error('Invitation not found');
|
||||
try {
|
||||
const instance = appService.invitations.find(
|
||||
(inv: any) => inv.data.invitationIdentifier === invitationId,
|
||||
);
|
||||
if (!instance) throw new Error("Invitation not found");
|
||||
|
||||
const inputs = selectedUtxos.map((utxo) => ({
|
||||
outpointTransactionHash: new Uint8Array(
|
||||
Buffer.from(utxo.outpointTransactionHash, 'hex'),
|
||||
),
|
||||
outpointIndex: utxo.outpointIndex,
|
||||
}));
|
||||
const inputs = selectedUtxos.map((utxo) => ({
|
||||
outpointTransactionHash: new Uint8Array(
|
||||
Buffer.from(utxo.outpointTransactionHash, "hex"),
|
||||
),
|
||||
outpointIndex: utxo.outpointIndex,
|
||||
}));
|
||||
|
||||
await instance.addInputs(inputs);
|
||||
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
|
||||
await refreshRequirements(invitationId);
|
||||
setStatus('Inputs and outputs added');
|
||||
return true;
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [invitationId, appService, showError, setStatus, refreshRequirements]);
|
||||
await instance.addInputs(inputs);
|
||||
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
|
||||
await refreshRequirements(invitationId);
|
||||
setStatus("Inputs and outputs added");
|
||||
return true;
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
},
|
||||
[invitationId, appService, showError, setStatus, refreshRequirements],
|
||||
);
|
||||
|
||||
/** Sign the invitation and broadcast the transaction. */
|
||||
const signAndBroadcast = useCallback(async (): Promise<boolean> => {
|
||||
if (!invitationId || !appService) return false;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Signing invitation...');
|
||||
setStatus("Signing invitation...");
|
||||
|
||||
try {
|
||||
const instance = appService.invitations.find(
|
||||
(inv: any) => inv.data.invitationIdentifier === invitationId,
|
||||
);
|
||||
if (!instance) throw new Error('Invitation not found');
|
||||
if (!instance) throw new Error("Invitation not found");
|
||||
|
||||
const complete = await refreshRequirements(invitationId);
|
||||
if (!complete) {
|
||||
showError('Invitation requirements are not complete yet.');
|
||||
showError("Invitation requirements are not complete yet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
await instance.sign();
|
||||
setStatus('Broadcasting transaction...');
|
||||
setStatus("Broadcasting transaction...");
|
||||
await instance.broadcast();
|
||||
setHasSignedAndBroadcasted(true);
|
||||
setStatus('Transaction signed and broadcasted');
|
||||
showInfo('Transaction signed and broadcasted.');
|
||||
setStatus("Transaction signed and broadcasted");
|
||||
showInfo("Transaction signed and broadcasted.");
|
||||
await refreshRequirements(invitationId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -240,7 +269,14 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [invitationId, appService, setStatus, showError, showInfo, refreshRequirements]);
|
||||
}, [
|
||||
invitationId,
|
||||
appService,
|
||||
setStatus,
|
||||
showError,
|
||||
showInfo,
|
||||
refreshRequirements,
|
||||
]);
|
||||
|
||||
return {
|
||||
invitation,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import { resolveActionRoles } from '../../../../utils/invitation-flow.js';
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
import { resolveActionRoles } from "../../../../utils/invitation-flow.js";
|
||||
|
||||
/**
|
||||
* Manages role selection state for the wizard.
|
||||
@@ -18,13 +18,17 @@ export function useRoleSelection(
|
||||
|
||||
/** Roles that can start this action, derived from template start entries. */
|
||||
const availableRoles = useMemo(() => {
|
||||
return resolveActionRoles(template, actionIdentifier, actionRolesFromNavigation);
|
||||
return resolveActionRoles(
|
||||
template,
|
||||
actionIdentifier,
|
||||
actionRolesFromNavigation,
|
||||
);
|
||||
}, [template, actionIdentifier, actionRolesFromNavigation]);
|
||||
|
||||
/** The role to use for the flow — either explicitly selected or auto-selected when only one exists. */
|
||||
const effectiveRole = roleIdentifier ?? (
|
||||
availableRoles.length === 1 ? availableRoles[0] : undefined
|
||||
);
|
||||
const effectiveRole =
|
||||
roleIdentifier ??
|
||||
(availableRoles.length === 1 ? availableRoles[0] : undefined);
|
||||
|
||||
// Auto-select when only one role exists.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { SelectableUTXO, VariableInput } from '../types.js';
|
||||
import type { Invitation } from '../../../../services/invitation.js';
|
||||
import { formatSatoshis } from '../../../theme.js';
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { SelectableUTXO, VariableInput } from "../types.js";
|
||||
import type { Invitation } from "../../../../services/invitation.js";
|
||||
import { formatSatoshis } from "../../../theme.js";
|
||||
import {
|
||||
autoSelectGreedyUtxos,
|
||||
mapUnspentOutputsToSelectable,
|
||||
} from '../../../../utils/invitation-flow.js';
|
||||
} from "../../../../utils/invitation-flow.js";
|
||||
|
||||
/**
|
||||
* Manages UTXO selection state for the wizard's inputs step.
|
||||
@@ -20,7 +20,10 @@ export function useUtxoSelection() {
|
||||
const [fee, setFee] = useState<bigint>(500n);
|
||||
|
||||
const selectedAmount = useMemo(
|
||||
() => availableUtxos.filter((u) => u.selected).reduce((sum, u) => sum + u.valueSatoshis, 0n),
|
||||
() =>
|
||||
availableUtxos
|
||||
.filter((u) => u.selected)
|
||||
.reduce((sum, u) => sum + u.valueSatoshis, 0n),
|
||||
[availableUtxos],
|
||||
);
|
||||
|
||||
@@ -55,38 +58,41 @@ export function useUtxoSelection() {
|
||||
* Query the invitation instance for suitable UTXOs and auto-select
|
||||
* greedily to meet the required amount.
|
||||
*/
|
||||
const loadUtxos = useCallback(async (
|
||||
invitationInstance: Invitation,
|
||||
templateIdentifier: string,
|
||||
variables: VariableInput[],
|
||||
setStatus: (msg: string) => void,
|
||||
): Promise<void> => {
|
||||
setStatus('Finding suitable UTXOs...');
|
||||
const loadUtxos = useCallback(
|
||||
async (
|
||||
invitationInstance: Invitation,
|
||||
templateIdentifier: string,
|
||||
variables: VariableInput[],
|
||||
setStatus: (msg: string) => void,
|
||||
): Promise<void> => {
|
||||
setStatus("Finding suitable UTXOs...");
|
||||
|
||||
// Derive required amount from variables that look like satoshi/amount fields.
|
||||
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);
|
||||
// Derive required amount from variables that look like satoshi/amount fields.
|
||||
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);
|
||||
|
||||
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||
templateIdentifier,
|
||||
});
|
||||
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||
templateIdentifier,
|
||||
});
|
||||
|
||||
const mapped = mapUnspentOutputsToSelectable(unspentOutputs);
|
||||
const autoSelected = autoSelectGreedyUtxos(mapped, requested + fee);
|
||||
setAvailableUtxos(autoSelected as SelectableUTXO[]);
|
||||
setStatus('Ready');
|
||||
}, [fee]);
|
||||
const mapped = mapUnspentOutputsToSelectable(unspentOutputs);
|
||||
const autoSelected = autoSelectGreedyUtxos(mapped, requested + fee);
|
||||
setAvailableUtxos(autoSelected as SelectableUTXO[]);
|
||||
setStatus("Ready");
|
||||
},
|
||||
[fee],
|
||||
);
|
||||
|
||||
/** Validate that the selection meets the required amounts. */
|
||||
const validate = useCallback((): string | null => {
|
||||
const selected = availableUtxos.filter((u) => u.selected);
|
||||
if (selected.length === 0) {
|
||||
return 'Please select at least one UTXO';
|
||||
return "Please select at least one UTXO";
|
||||
}
|
||||
if (selectedAmount < requiredAmount + fee) {
|
||||
return `Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import type { VariableInput } from '../types.js';
|
||||
import { useState, useCallback } from "react";
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
import type { VariableInput } from "../types.js";
|
||||
|
||||
/**
|
||||
* Manages the variable input state for the wizard's variables step.
|
||||
@@ -15,27 +15,30 @@ export function useVariableInputs() {
|
||||
* Populate the variable list from the template's role requirements.
|
||||
* Calling this again replaces the current variables entirely.
|
||||
*/
|
||||
const initFromTemplate = useCallback((
|
||||
template: XOTemplate,
|
||||
actionIdentifier: string,
|
||||
roleIdentifier: string,
|
||||
) => {
|
||||
const action = template.actions?.[actionIdentifier];
|
||||
const role = action?.roles?.[roleIdentifier];
|
||||
const varIds = role?.requirements?.variables ?? [];
|
||||
const initFromTemplate = useCallback(
|
||||
(
|
||||
template: XOTemplate,
|
||||
actionIdentifier: string,
|
||||
roleIdentifier: string,
|
||||
) => {
|
||||
const action = template.actions?.[actionIdentifier];
|
||||
const role = action?.roles?.[roleIdentifier];
|
||||
const varIds = role?.requirements?.variables ?? [];
|
||||
|
||||
const varInputs: VariableInput[] = varIds.map((varId) => {
|
||||
const varDef = template.variables?.[varId];
|
||||
return {
|
||||
id: varId,
|
||||
name: varDef?.name || varId,
|
||||
type: varDef?.type || 'string',
|
||||
hint: varDef?.hint,
|
||||
value: '',
|
||||
};
|
||||
});
|
||||
setVariables(varInputs);
|
||||
}, []);
|
||||
const varInputs: VariableInput[] = varIds.map((varId) => {
|
||||
const varDef = template.variables?.[varId];
|
||||
return {
|
||||
id: varId,
|
||||
name: varDef?.name || varId,
|
||||
type: varDef?.type || "string",
|
||||
hint: varDef?.hint,
|
||||
value: "",
|
||||
};
|
||||
});
|
||||
setVariables(varInputs);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Update a single variable's value by index. */
|
||||
const updateVariable = useCallback((index: number, value: string) => {
|
||||
@@ -51,9 +54,11 @@ export function useVariableInputs() {
|
||||
|
||||
/** Returns an error message if any required variable is empty, or null if valid. */
|
||||
const validate = useCallback((): string | null => {
|
||||
const emptyVars = variables.filter((v) => !v.value || v.value.trim() === '');
|
||||
const emptyVars = variables.filter(
|
||||
(v) => !v.value || v.value.trim() === "",
|
||||
);
|
||||
if (emptyVars.length > 0) {
|
||||
return `Please enter values for: ${emptyVars.map((v) => v.name).join(', ')}`;
|
||||
return `Please enter values for: ${emptyVars.map((v) => v.name).join(", ")}`;
|
||||
}
|
||||
return null;
|
||||
}, [variables]);
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { FocusArea, ButtonFocus } from '../types.js';
|
||||
import { useState, useCallback } from "react";
|
||||
import type { FocusArea, ButtonFocus } from "../types.js";
|
||||
|
||||
/**
|
||||
* Manages which area of the wizard UI has keyboard focus and
|
||||
* which specific element within that area is highlighted.
|
||||
*/
|
||||
export function useWizardFocus() {
|
||||
const [focusArea, setFocusArea] = useState<FocusArea>('content');
|
||||
const [focusedButton, setFocusedButton] = useState<ButtonFocus>('next');
|
||||
const [focusArea, setFocusArea] = useState<FocusArea>("content");
|
||||
const [focusedButton, setFocusedButton] = useState<ButtonFocus>("next");
|
||||
const [focusedInput, setFocusedInput] = useState(0);
|
||||
|
||||
/** Reset focus to the content area at the first element. */
|
||||
const resetToContent = useCallback(() => {
|
||||
setFocusArea('content');
|
||||
setFocusArea("content");
|
||||
setFocusedInput(0);
|
||||
}, []);
|
||||
|
||||
/** Move focus to the button bar. */
|
||||
const moveToButtons = useCallback((button: ButtonFocus = 'next') => {
|
||||
setFocusArea('buttons');
|
||||
const moveToButtons = useCallback((button: ButtonFocus = "next") => {
|
||||
setFocusArea("buttons");
|
||||
setFocusedButton(button);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useBlockableInput } from '../../../hooks/useInputLayer.js';
|
||||
import type { ActionWizardState } from './useActionWizard.js';
|
||||
import { useBlockableInput } from "../../../hooks/useInputLayer.js";
|
||||
import type { ActionWizardState } from "./useActionWizard.js";
|
||||
|
||||
/**
|
||||
* Keyboard input handler for the action wizard.
|
||||
@@ -18,30 +18,34 @@ export function useWizardKeyboard(wizard: ActionWizardState): void {
|
||||
}
|
||||
|
||||
// ── Content-area: step-specific input handling ────────
|
||||
if (wizard.focusArea === 'content') {
|
||||
if (wizard.currentStepData?.type === 'role-select') {
|
||||
if (wizard.focusArea === "content") {
|
||||
if (wizard.currentStepData?.type === "role-select") {
|
||||
handleRoleSelectInput(wizard, input, key);
|
||||
return;
|
||||
}
|
||||
if (wizard.currentStepData?.type === 'inputs') {
|
||||
if (wizard.currentStepData?.type === "inputs") {
|
||||
handleInputsStepInput(wizard, input, key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Button bar navigation + activation ────────────────
|
||||
if (wizard.focusArea === 'buttons') {
|
||||
if (wizard.focusArea === "buttons") {
|
||||
handleButtonBarInput(wizard, key);
|
||||
}
|
||||
|
||||
// ── Global shortcuts ──────────────────────────────────
|
||||
if (input === 'c' && wizard.currentStepData?.type === 'publish' && wizard.invitationId) {
|
||||
if (
|
||||
input === "c" &&
|
||||
wizard.currentStepData?.type === "publish" &&
|
||||
wizard.invitationId
|
||||
) {
|
||||
wizard.copyId();
|
||||
}
|
||||
if (input === 'a' && wizard.currentStepData?.type === 'inputs') {
|
||||
if (input === "a" && wizard.currentStepData?.type === "inputs") {
|
||||
wizard.selectAll();
|
||||
}
|
||||
if (input === 'n' && wizard.currentStepData?.type === 'inputs') {
|
||||
if (input === "n" && wizard.currentStepData?.type === "inputs") {
|
||||
wizard.deselectAll();
|
||||
}
|
||||
},
|
||||
@@ -52,10 +56,10 @@ export function useWizardKeyboard(wizard: ActionWizardState): void {
|
||||
// ── Tab cycling ─────────────────────────────────────────────────
|
||||
|
||||
function handleTab(wizard: ActionWizardState): void {
|
||||
if (wizard.focusArea === 'content') {
|
||||
if (wizard.focusArea === "content") {
|
||||
// Within role-select, tab through roles before moving to buttons
|
||||
if (
|
||||
wizard.currentStepData?.type === 'role-select' &&
|
||||
wizard.currentStepData?.type === "role-select" &&
|
||||
wizard.availableRoles.length > 0 &&
|
||||
wizard.selectedRoleIndex < wizard.availableRoles.length - 1
|
||||
) {
|
||||
@@ -65,7 +69,7 @@ function handleTab(wizard: ActionWizardState): void {
|
||||
|
||||
// Within inputs, tab through UTXOs before moving to buttons
|
||||
if (
|
||||
wizard.currentStepData?.type === 'inputs' &&
|
||||
wizard.currentStepData?.type === "inputs" &&
|
||||
wizard.availableUtxos.length > 0 &&
|
||||
wizard.selectedUtxoIndex < wizard.availableUtxos.length - 1
|
||||
) {
|
||||
@@ -74,16 +78,16 @@ function handleTab(wizard: ActionWizardState): void {
|
||||
}
|
||||
|
||||
// Move to button bar
|
||||
wizard.setFocusArea('buttons');
|
||||
wizard.setFocusedButton('next');
|
||||
wizard.setFocusArea("buttons");
|
||||
wizard.setFocusedButton("next");
|
||||
} else {
|
||||
// Cycle through buttons, then wrap back to content
|
||||
if (wizard.focusedButton === 'back') {
|
||||
wizard.setFocusedButton('cancel');
|
||||
} else if (wizard.focusedButton === 'cancel') {
|
||||
wizard.setFocusedButton('next');
|
||||
if (wizard.focusedButton === "back") {
|
||||
wizard.setFocusedButton("cancel");
|
||||
} else if (wizard.focusedButton === "cancel") {
|
||||
wizard.setFocusedButton("next");
|
||||
} else {
|
||||
wizard.setFocusArea('content');
|
||||
wizard.setFocusArea("content");
|
||||
wizard.setFocusedInput(0);
|
||||
wizard.setSelectedUtxoIndex(0);
|
||||
wizard.setSelectedRoleIndex(0);
|
||||
@@ -120,7 +124,7 @@ function handleInputsStepInput(
|
||||
wizard.setSelectedUtxoIndex((p) =>
|
||||
Math.min(wizard.availableUtxos.length - 1, p + 1),
|
||||
);
|
||||
} else if (key.return || input === ' ') {
|
||||
} else if (key.return || input === " ") {
|
||||
wizard.toggleUtxoSelection(wizard.selectedUtxoIndex);
|
||||
}
|
||||
}
|
||||
@@ -133,17 +137,17 @@ function handleButtonBarInput(
|
||||
): void {
|
||||
if (key.leftArrow) {
|
||||
wizard.setFocusedButton((p) =>
|
||||
p === 'next' ? 'cancel' : p === 'cancel' ? 'back' : 'back',
|
||||
p === "next" ? "cancel" : p === "cancel" ? "back" : "back",
|
||||
);
|
||||
} else if (key.rightArrow) {
|
||||
wizard.setFocusedButton((p) =>
|
||||
p === 'back' ? 'cancel' : p === 'cancel' ? 'next' : 'next',
|
||||
p === "back" ? "cancel" : p === "cancel" ? "next" : "next",
|
||||
);
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
if (wizard.focusedButton === 'back') wizard.previousStep();
|
||||
else if (wizard.focusedButton === 'cancel') wizard.cancel();
|
||||
else if (wizard.focusedButton === 'next') wizard.nextStep();
|
||||
if (wizard.focusedButton === "back") wizard.previousStep();
|
||||
else if (wizard.focusedButton === "cancel") wizard.cancel();
|
||||
else if (wizard.focusedButton === "next") wizard.nextStep();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { StepConfig, WizardStep } from '../types.js';
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import type { StepConfig, WizardStep } from "../types.js";
|
||||
|
||||
/**
|
||||
* Generic step navigation driven by an array of StepConfig objects.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './ActionWizardScreen.js';
|
||||
export * from './hooks/useActionWizard.js';
|
||||
export * from './types.js';
|
||||
export * from './steps/index.js';
|
||||
export * from './flows/index.js';
|
||||
export * from "./ActionWizardScreen.js";
|
||||
export * from "./hooks/useActionWizard.js";
|
||||
export * from "./types.js";
|
||||
export * from "./steps/index.js";
|
||||
export * from "./flows/index.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * from './RoleSelectStep.js';
|
||||
export * from './VariablesStep.js';
|
||||
export * from './InputsStep.js';
|
||||
export * from './ReviewStep.js';
|
||||
export * from './PublishStep.js';
|
||||
export * from './DataResultStep.js';
|
||||
export * from "./RoleSelectStep.js";
|
||||
export * from "./VariablesStep.js";
|
||||
export * from "./InputsStep.js";
|
||||
export * from "./ReviewStep.js";
|
||||
export * from "./PublishStep.js";
|
||||
export * from "./DataResultStep.js";
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
*/
|
||||
|
||||
/** Supported step types in the wizard. */
|
||||
export type StepType = 'role-select' | 'variables' | 'inputs' | 'review' | 'publish' | 'result';
|
||||
export type StepType =
|
||||
| "role-select"
|
||||
| "variables"
|
||||
| "inputs"
|
||||
| "review"
|
||||
| "publish"
|
||||
| "result";
|
||||
|
||||
/** A step displayed in the wizard's progress indicator. */
|
||||
export interface WizardStep {
|
||||
@@ -57,10 +63,10 @@ export interface SelectableUTXO {
|
||||
}
|
||||
|
||||
/** Which area of the wizard UI currently has keyboard focus. */
|
||||
export type FocusArea = 'content' | 'buttons';
|
||||
export type FocusArea = "content" | "buttons";
|
||||
|
||||
/** Which button in the bottom bar is focused. */
|
||||
export type ButtonFocus = 'back' | 'cancel' | 'next';
|
||||
export type ButtonFocus = "back" | "cancel" | "next";
|
||||
|
||||
/** A computed data result from a data-only action. */
|
||||
export interface DataResult {
|
||||
|
||||
@@ -719,7 +719,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
{importingId && appService && (
|
||||
<InvitationImportFlow
|
||||
invitationId={importingId}
|
||||
mode="dialog"
|
||||
mode="screen"
|
||||
appService={appService}
|
||||
onClose={handleImportFlowClose}
|
||||
showError={showError}
|
||||
|
||||
@@ -15,7 +15,6 @@ export function FetchInvitationStep({
|
||||
invitationId,
|
||||
appService,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
}: FetchStepProps): React.ReactElement {
|
||||
const [status, setStatus] = useState<'loading' | 'error'>('loading');
|
||||
|
||||
@@ -22,8 +22,6 @@ const DUST_THRESHOLD = 546n;
|
||||
|
||||
export function InputsSelectStep({
|
||||
invitation,
|
||||
template,
|
||||
selectedRole,
|
||||
appService,
|
||||
onComplete,
|
||||
onCancel,
|
||||
@@ -93,8 +91,6 @@ export function InputsSelectStep({
|
||||
outputIdentifiers.add(output.output);
|
||||
}
|
||||
|
||||
console.log('outputIdentifiers', Array.from(outputIdentifiers));
|
||||
|
||||
// Create a map of the utxoID to suitable resource
|
||||
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
|
||||
for (const outputIdentifier of outputIdentifiers) {
|
||||
@@ -102,14 +98,11 @@ export function InputsSelectStep({
|
||||
|
||||
outputIdentifier,
|
||||
});
|
||||
console.log('suitableResources', outputIdentifier, JSON.stringify(suitableResources, null, 2));
|
||||
for (const suitableResource of suitableResources) {
|
||||
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('utxoIdToSuitableResource', JSON.stringify(utxoIdToSuitableResource, null, 2));
|
||||
|
||||
const selectable = mapUnspentOutputsToSelectable(Array.from(utxoIdToSuitableResource.values()));
|
||||
const autoSelected = autoSelectGreedyUtxos(selectable, required + fee);
|
||||
setUtxos(autoSelected as SelectableUTXO[]);
|
||||
@@ -155,7 +148,7 @@ export function InputsSelectStep({
|
||||
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
||||
} else if (input === 'n') {
|
||||
setUtxos(prev => prev.map(u => ({ ...u, selected: false })));
|
||||
} else if (key.tab) {
|
||||
} else if (key.return) {
|
||||
if (hasEnough) {
|
||||
onComplete(utxos.filter(u => u.selected));
|
||||
}
|
||||
@@ -239,7 +232,7 @@ export function InputsSelectStep({
|
||||
{/* Navigation hint */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
↑↓: Navigate • Space: Toggle • a: All • n: None • Tab: Confirm • Esc: Cancel
|
||||
↑↓: Navigate • Space: Toggle • a: All • n: None • return: Confirm • Esc: Cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -108,21 +108,23 @@ export function PreviewInvitationStep({
|
||||
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
|
||||
</Box>
|
||||
|
||||
{filledRoles.size === 0 ? (
|
||||
<Box>
|
||||
<Text color={colors.textMuted}> None yet</Text>
|
||||
</Box>
|
||||
) : (
|
||||
Array.from(filledRoles).map(role => {
|
||||
const roleInfoRaw = template?.roles?.[role];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
return (
|
||||
<Box key={role}>
|
||||
<Text color={colors.text}> • {roleInfo?.name ?? role}</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<Box marginLeft={1}>
|
||||
{filledRoles.size === 0 ? (
|
||||
<Box>
|
||||
<Text color={colors.textMuted}> None yet</Text>
|
||||
</Box>
|
||||
) : (
|
||||
Array.from(filledRoles).map(role => {
|
||||
const roleInfoRaw = template?.roles?.[role];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
return (
|
||||
<Box key={role}>
|
||||
<Text color={colors.text}> • {roleInfo?.name ?? role}</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Inputs */}
|
||||
@@ -131,48 +133,52 @@ export function PreviewInvitationStep({
|
||||
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||
</Box>
|
||||
|
||||
{inputs.length === 0 ? (
|
||||
<Box>
|
||||
<Text color={colors.textMuted}> None yet</Text>
|
||||
</Box>
|
||||
) : (
|
||||
inputs.map((input, idx) => {
|
||||
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
|
||||
return (
|
||||
<Box key={`input-${idx}`}>
|
||||
<Text color={colors.text}>
|
||||
{' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<Box marginLeft={1}>
|
||||
{inputs.length === 0 ? (
|
||||
<Box>
|
||||
<Text color={colors.textMuted}> None yet</Text>
|
||||
</Box>
|
||||
) : (
|
||||
inputs.map((input, idx) => {
|
||||
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
|
||||
return (
|
||||
<Box key={`input-${idx}`}>
|
||||
<Text color={colors.text}>
|
||||
{' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Outputs */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="column" marginBottom={1} marginLeft={1}>
|
||||
<Box>
|
||||
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||
</Box>
|
||||
|
||||
{outputs.length === 0 ? (
|
||||
<Box>
|
||||
<Text color={colors.textMuted}> None yet</Text>
|
||||
</Box>
|
||||
) : (
|
||||
outputs.map((output, idx) => {
|
||||
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
|
||||
return (
|
||||
<Box key={`output-${idx}`}>
|
||||
<Text color={colors.text}>
|
||||
{' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<Box marginLeft={1}>
|
||||
{outputs.length === 0 ? (
|
||||
<Box>
|
||||
<Text color={colors.textMuted}>None yet</Text>
|
||||
</Box>
|
||||
) : (
|
||||
outputs.map((output, idx) => {
|
||||
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
|
||||
return (
|
||||
<Box key={`output-${idx}`}>
|
||||
<Text color={colors.text}>
|
||||
{' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Variables */}
|
||||
@@ -181,25 +187,27 @@ export function PreviewInvitationStep({
|
||||
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||
</Box>
|
||||
|
||||
{variables.length === 0 ? (
|
||||
<Box>
|
||||
<Text color={colors.textMuted}> None set</Text>
|
||||
</Box>
|
||||
) : (
|
||||
variables.map((variable, idx) => {
|
||||
const varTemplate = template?.variables?.[variable.variableIdentifier];
|
||||
const displayValue = typeof variable.value === 'bigint'
|
||||
? variable.value.toString()
|
||||
: String(variable.value);
|
||||
return (
|
||||
<Box key={`var-${idx}`}>
|
||||
<Text color={colors.text}>
|
||||
{' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<Box marginLeft={1}>
|
||||
{variables.length === 0 ? (
|
||||
<Box>
|
||||
<Text color={colors.textMuted}> None set</Text>
|
||||
</Box>
|
||||
) : (
|
||||
variables.map((variable, idx) => {
|
||||
const varTemplate = template?.variables?.[variable.variableIdentifier];
|
||||
const displayValue = typeof variable.value === 'bigint'
|
||||
? variable.value.toString()
|
||||
: String(variable.value);
|
||||
return (
|
||||
<Box key={`var-${idx}`}>
|
||||
<Text color={colors.text}>
|
||||
{' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Navigation hint */}
|
||||
|
||||
@@ -26,7 +26,6 @@ export function ReviewStep({
|
||||
selectedInputs,
|
||||
requiredAmount,
|
||||
changeAmount,
|
||||
appService,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
|
||||
@@ -5,14 +5,19 @@
|
||||
* The flow controller (`InvitationImportFlow`) accumulates data and passes it forward.
|
||||
*/
|
||||
|
||||
import type { Invitation } from '../../../../services/invitation.js';
|
||||
import type { AppService } from '../../../../services/app.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import type { Invitation } from "../../../../services/invitation.js";
|
||||
import type { AppService } from "../../../../services/app.js";
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
|
||||
// ── Step definitions ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Identifies each step in the import flow. */
|
||||
export type ImportStepType = 'fetch' | 'preview' | 'role-select' | 'inputs-select' | 'review';
|
||||
export type ImportStepType =
|
||||
| "fetch"
|
||||
| "preview"
|
||||
| "role-select"
|
||||
| "inputs-select"
|
||||
| "review";
|
||||
|
||||
/** A single step descriptor used by the flow controller and step indicator. */
|
||||
export interface ImportStep {
|
||||
@@ -22,17 +27,17 @@ export interface ImportStep {
|
||||
|
||||
/** The ordered list of steps in the import flow. */
|
||||
export const IMPORT_STEPS: ImportStep[] = [
|
||||
{ name: 'Fetch', type: 'fetch' },
|
||||
{ name: 'Preview', type: 'preview' },
|
||||
{ name: 'Select Role', type: 'role-select' },
|
||||
{ name: 'Select Inputs', type: 'inputs-select' },
|
||||
{ name: 'Review', type: 'review' },
|
||||
{ name: "Fetch", type: "fetch" },
|
||||
{ name: "Preview", type: "preview" },
|
||||
{ name: "Select Role", type: "role-select" },
|
||||
{ name: "Select Inputs", type: "inputs-select" },
|
||||
{ name: "Review", type: "review" },
|
||||
];
|
||||
|
||||
// ── Display mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Controls whether the import flow renders as a dialog overlay or a full screen. */
|
||||
export type ImportFlowMode = 'dialog' | 'screen';
|
||||
export type ImportFlowMode = "dialog" | "screen";
|
||||
|
||||
// ── UTXO selection ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* Defines colors, styles, and visual constants used throughout the application.
|
||||
*/
|
||||
|
||||
import type { TextProps } from 'ink';
|
||||
import type { TextProps } from "ink";
|
||||
|
||||
/**
|
||||
* Color type - supports Ink color names.
|
||||
*/
|
||||
export type Color = TextProps['color'];
|
||||
export type Color = TextProps["color"];
|
||||
|
||||
/**
|
||||
* Color palette for the application.
|
||||
@@ -16,33 +16,33 @@ export type Color = TextProps['color'];
|
||||
*/
|
||||
export const colors = {
|
||||
// Primary colors
|
||||
primary: 'cyan' as Color,
|
||||
secondary: 'blue' as Color,
|
||||
accent: 'magenta' as Color,
|
||||
|
||||
primary: "cyan" as Color,
|
||||
secondary: "blue" as Color,
|
||||
accent: "magenta" as Color,
|
||||
|
||||
// Status colors
|
||||
success: 'green' as Color,
|
||||
warning: 'yellow' as Color,
|
||||
error: 'red' as Color,
|
||||
info: 'cyan' as Color,
|
||||
|
||||
success: "green" as Color,
|
||||
warning: "yellow" as Color,
|
||||
error: "red" as Color,
|
||||
info: "cyan" as Color,
|
||||
|
||||
// Text colors
|
||||
text: 'white' as Color,
|
||||
textMuted: 'gray' as Color,
|
||||
textHighlight: 'whiteBright' as Color,
|
||||
|
||||
text: "white" as Color,
|
||||
textMuted: "gray" as Color,
|
||||
textHighlight: "whiteBright" as Color,
|
||||
|
||||
// Background colors
|
||||
bg: 'black' as Color,
|
||||
bgSelected: 'blue' as Color,
|
||||
bgHover: 'gray' as Color,
|
||||
|
||||
bg: "black" as Color,
|
||||
bgSelected: "blue" as Color,
|
||||
bgHover: "gray" as Color,
|
||||
|
||||
// Border colors
|
||||
border: 'cyan' as Color,
|
||||
borderFocused: 'yellowBright' as Color,
|
||||
borderMuted: 'gray' as Color,
|
||||
|
||||
border: "cyan" as Color,
|
||||
borderFocused: "yellowBright" as Color,
|
||||
borderMuted: "gray" as Color,
|
||||
|
||||
// Focus highlight color (very visible)
|
||||
focus: 'yellowBright' as Color,
|
||||
focus: "yellowBright" as Color,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -76,7 +76,7 @@ export const logo = `
|
||||
/**
|
||||
* Small logo for status bar.
|
||||
*/
|
||||
export const logoSmall = 'XO Wallet';
|
||||
export const logoSmall = "XO Wallet";
|
||||
|
||||
/**
|
||||
* Helper to format satoshis for display.
|
||||
@@ -84,7 +84,7 @@ export const logoSmall = 'XO Wallet';
|
||||
* @returns Formatted string with BCH amount
|
||||
*/
|
||||
export function formatSatoshis(satoshis: bigint | number): string {
|
||||
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
|
||||
const value = typeof satoshis === "bigint" ? satoshis : BigInt(satoshis);
|
||||
const bch = Number(value) / 100_000_000;
|
||||
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export function formatSatoshis(satoshis: bigint | number): string {
|
||||
*/
|
||||
export function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.slice(0, maxLength - 3) + '...';
|
||||
return str.slice(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
* Shared types for the CLI TUI.
|
||||
*/
|
||||
|
||||
import type { AppService } from '../services/app.js';
|
||||
import type { AppConfig } from '../app.js';
|
||||
import type { AppService } from "../services/app.js";
|
||||
import type { AppConfig } from "../app.js";
|
||||
|
||||
/**
|
||||
* Screen names for navigation.
|
||||
*/
|
||||
export type ScreenName =
|
||||
| 'seed-input'
|
||||
| 'wallet'
|
||||
| 'templates'
|
||||
| 'wizard'
|
||||
| 'invitations'
|
||||
| 'transaction';
|
||||
export type ScreenName =
|
||||
| "seed-input"
|
||||
| "wallet"
|
||||
| "templates"
|
||||
| "wizard"
|
||||
| "invitations"
|
||||
| "transaction";
|
||||
|
||||
/**
|
||||
* Navigation context data that can be passed between screens.
|
||||
@@ -81,7 +81,7 @@ export interface DialogState {
|
||||
/** Whether dialog is visible */
|
||||
visible: boolean;
|
||||
/** Dialog type */
|
||||
type: 'error' | 'info' | 'confirm';
|
||||
type: "error" | "info" | "confirm";
|
||||
/** Dialog message */
|
||||
message: string;
|
||||
/** Callback for confirm dialog */
|
||||
|
||||
@@ -2,45 +2,49 @@
|
||||
* Cross-platform clipboard utility with multiple fallback methods.
|
||||
*/
|
||||
|
||||
import clipboardy from 'clipboardy';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import clipboardy from "clipboardy";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Attempts to copy text to clipboard using multiple methods.
|
||||
* Tries native commands first (most reliable), then clipboardy as fallback.
|
||||
*
|
||||
*
|
||||
* @param text - The text to copy to clipboard
|
||||
* @returns Promise that resolves on success, rejects with error message on failure
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
|
||||
|
||||
// Escape the text for shell commands
|
||||
const escapedText = text.replace(/'/g, "'\\''");
|
||||
|
||||
|
||||
// Try native commands first - they're more reliable
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
if (platform === "darwin") {
|
||||
// macOS - use pbcopy directly
|
||||
await execAsync(`printf '%s' '${escapedText}' | pbcopy`);
|
||||
return;
|
||||
} else if (platform === 'linux') {
|
||||
} else if (platform === "linux") {
|
||||
// Linux - try xclip, then xsel
|
||||
try {
|
||||
await execAsync(`printf '%s' '${escapedText}' | xclip -selection clipboard`);
|
||||
await execAsync(
|
||||
`printf '%s' '${escapedText}' | xclip -selection clipboard`,
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
try {
|
||||
await execAsync(`printf '%s' '${escapedText}' | xsel --clipboard --input`);
|
||||
await execAsync(
|
||||
`printf '%s' '${escapedText}' | xsel --clipboard --input`,
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to clipboardy
|
||||
}
|
||||
}
|
||||
} else if (platform === 'win32') {
|
||||
} else if (platform === "win32") {
|
||||
// Windows - use clip.exe
|
||||
await execAsync(`echo|set /p="${text}" | clip`);
|
||||
return;
|
||||
@@ -48,7 +52,7 @@ export async function copyToClipboard(text: string): Promise<void> {
|
||||
} catch {
|
||||
// Native command failed, try clipboardy
|
||||
}
|
||||
|
||||
|
||||
// Fallback to clipboardy
|
||||
try {
|
||||
clipboardy.writeSync(text);
|
||||
@@ -56,7 +60,7 @@ export async function copyToClipboard(text: string): Promise<void> {
|
||||
} catch {
|
||||
// clipboardy also failed
|
||||
}
|
||||
|
||||
|
||||
// All methods failed
|
||||
throw new Error(`Clipboard not available. Install xclip or xsel on Linux.`);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Handles BCH Mnemonic parsing to/from URL form.
|
||||
* Pulled directly from the old stack package.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { z } from "zod";
|
||||
|
||||
export type BCHMnemonicURLRaw = {
|
||||
entropy: Uint8Array;
|
||||
@@ -17,7 +17,7 @@ export type BCHMnemonicURLRaw = {
|
||||
* Handles BCHMnemonic URLs
|
||||
*/
|
||||
export class BCHMnemonicURL {
|
||||
static PROTOCOL = 'bch-mnemonic';
|
||||
static PROTOCOL = "bch-mnemonic";
|
||||
|
||||
/**
|
||||
* Check if a URL is a valid wallet backup URL
|
||||
@@ -48,7 +48,7 @@ export class BCHMnemonicURL {
|
||||
}
|
||||
|
||||
// Decode the entropy.
|
||||
const entropy = new Uint8Array(Buffer.from(url.pathname, 'base64'));
|
||||
const entropy = new Uint8Array(Buffer.from(url.pathname, "base64"));
|
||||
|
||||
// Pick out our encoding keys from the URL
|
||||
const params = BCHMnemonicURL.schema.parse(
|
||||
@@ -74,7 +74,7 @@ export class BCHMnemonicURL {
|
||||
static fromRaw(raw: BCHMnemonicURLRaw): BCHMnemonicURL {
|
||||
// Add entropy validation
|
||||
if (!raw.entropy || raw.entropy.length === 0) {
|
||||
throw new Error('Invalid entropy: must be non-empty');
|
||||
throw new Error("Invalid entropy: must be non-empty");
|
||||
}
|
||||
|
||||
// Validate entropy length (typically 16, 20, 24, 28, or 32 bytes for BIP39)
|
||||
@@ -100,7 +100,7 @@ export class BCHMnemonicURL {
|
||||
*/
|
||||
toURL(): string {
|
||||
// Conver the mnemonic words into the entropy used to derive the mnemonic words
|
||||
const entropyBase64 = Buffer.from(this.raw.entropy).toString('base64');
|
||||
const entropyBase64 = Buffer.from(this.raw.entropy).toString("base64");
|
||||
|
||||
// Create a new URL object with the prefix and the base64 encoded mnemonic
|
||||
const url = new URL(`${BCHMnemonicURL.PROTOCOL}:${entropyBase64}`);
|
||||
@@ -135,24 +135,24 @@ export class BCHMnemonicURL {
|
||||
}
|
||||
|
||||
static ENCODING_KEYS = {
|
||||
language: 'l',
|
||||
passphrase: 'p',
|
||||
comment: 'c',
|
||||
path: 'd',
|
||||
startHeight: 'h',
|
||||
language: "l",
|
||||
passphrase: "p",
|
||||
comment: "c",
|
||||
path: "d",
|
||||
startHeight: "h",
|
||||
} as const;
|
||||
|
||||
static SUPPORTED_LANGUAGES = [
|
||||
'en',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'ja',
|
||||
'es',
|
||||
'pt',
|
||||
'ko',
|
||||
'fr',
|
||||
'it',
|
||||
'cs',
|
||||
"en",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"ja",
|
||||
"es",
|
||||
"pt",
|
||||
"ko",
|
||||
"fr",
|
||||
"it",
|
||||
"cs",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,7 +71,7 @@ export class ExponentialBackoff {
|
||||
fn: () => Promise<T>,
|
||||
onError = (_error: Error) => {},
|
||||
): Promise<T> {
|
||||
let lastError: Error = new Error('Exponential backoff: Max retries hit');
|
||||
let lastError: Error = new Error("Exponential backoff: Max retries hit");
|
||||
|
||||
let attempt = 0;
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Extended JSON encoding/decoding utilities.
|
||||
* Handles BigInt and Uint8Array serialization for communication with sync-server.
|
||||
*
|
||||
*
|
||||
* TODO: These are intended as temporary stand-ins until this functionality has been implemented directly in LibAuth.
|
||||
* We are doing this so that we may better standardize with the rest of the BCH eco-system in future.
|
||||
* See: https://github.com/bitauth/libauth/pull/108
|
||||
*/
|
||||
|
||||
import { binToHex, hexToBin } from '@bitauth/libauth';
|
||||
import { binToHex, hexToBin } from "@bitauth/libauth";
|
||||
|
||||
/**
|
||||
* Replaces BigInt and Uint8Array values with their ExtJSON string representations.
|
||||
@@ -15,7 +15,7 @@ import { binToHex, hexToBin } from '@bitauth/libauth';
|
||||
* @returns The replaced value as an ExtJSON string, or the original value
|
||||
*/
|
||||
export const extendedJsonReplacer = function (value: unknown): unknown {
|
||||
if (typeof value === 'bigint') {
|
||||
if (typeof value === "bigint") {
|
||||
return `<bigint: ${value.toString()}n>`;
|
||||
} else if (value instanceof Uint8Array) {
|
||||
return `<Uint8Array: ${binToHex(value)}>`;
|
||||
@@ -36,7 +36,7 @@ export const extendedJsonReviver = function (value: unknown): unknown {
|
||||
|
||||
// Only perform a check if the value is a string.
|
||||
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
|
||||
if (typeof value === 'string') {
|
||||
if (typeof value === "string") {
|
||||
// Check if this value matches an Extended JSON encoded bigint.
|
||||
const bigintMatch = value.match(bigIntRegex);
|
||||
if (bigintMatch) {
|
||||
@@ -70,7 +70,7 @@ export const encodeExtendedJsonObject = function (value: unknown): unknown {
|
||||
// If this is an object type (and it is not null - which is technically an "object")...
|
||||
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!ArrayBuffer.isView(value)
|
||||
) {
|
||||
@@ -83,7 +83,9 @@ export const encodeExtendedJsonObject = function (value: unknown): unknown {
|
||||
const encodedObject: Record<string, unknown> = {};
|
||||
|
||||
// Iterate through each entry and encode it to extended JSON.
|
||||
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
|
||||
for (const [key, valueToEncode] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
|
||||
}
|
||||
|
||||
@@ -104,7 +106,7 @@ export const decodeExtendedJsonObject = function (value: unknown): unknown {
|
||||
// If this is an object type (and it is not null - which is technically an "object")...
|
||||
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!ArrayBuffer.isView(value)
|
||||
) {
|
||||
@@ -117,7 +119,9 @@ export const decodeExtendedJsonObject = function (value: unknown): unknown {
|
||||
const decodedObject: Record<string, unknown> = {};
|
||||
|
||||
// Iterate through each entry and decode it from extended JSON.
|
||||
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
|
||||
for (const [key, valueToEncode] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import type { HistoryItem, HistoryInvitationItem, HistoryUtxoItem } from '../services/history.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryInvitationItem,
|
||||
HistoryUtxoItem,
|
||||
} from "../services/history.js";
|
||||
|
||||
export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text';
|
||||
export type HistoryColorName =
|
||||
| "info"
|
||||
| "warning"
|
||||
| "success"
|
||||
| "error"
|
||||
| "muted"
|
||||
| "text";
|
||||
|
||||
export type HistoryRowType = 'invitation' | 'invitation_input' | 'invitation_output' | 'utxo';
|
||||
export type HistoryRowType =
|
||||
| "invitation"
|
||||
| "invitation_input"
|
||||
| "invitation_output"
|
||||
| "utxo";
|
||||
|
||||
export interface HistoryDisplayRow {
|
||||
id: string;
|
||||
@@ -20,14 +34,16 @@ export function formatHistoryDate(timestamp?: number): string | undefined {
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow[] {
|
||||
export function buildHistoryDisplayRows(
|
||||
items: HistoryItem[],
|
||||
): HistoryDisplayRow[] {
|
||||
const rows: HistoryDisplayRow[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind === 'invitation') {
|
||||
if (item.kind === "invitation") {
|
||||
rows.push({
|
||||
id: item.id,
|
||||
type: 'invitation',
|
||||
type: "invitation",
|
||||
label: item.description,
|
||||
timestamp: item.createdAtTimestamp,
|
||||
isNested: false,
|
||||
@@ -35,10 +51,13 @@ export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow
|
||||
});
|
||||
|
||||
for (const input of item.inputs) {
|
||||
const satsPrefix = input.valueSatoshis !== undefined ? `${input.valueSatoshis.toLocaleString()} sats ` : '';
|
||||
const satsPrefix =
|
||||
input.valueSatoshis !== undefined
|
||||
? `${input.valueSatoshis.toLocaleString()} sats `
|
||||
: "";
|
||||
rows.push({
|
||||
id: `${item.id}-input-${input.id}`,
|
||||
type: 'invitation_input',
|
||||
type: "invitation_input",
|
||||
label: `${satsPrefix}${input.outpoint.txid}:${input.outpoint.index}`,
|
||||
description: input.description,
|
||||
isNested: true,
|
||||
@@ -50,8 +69,11 @@ export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow
|
||||
for (const output of item.outputs) {
|
||||
rows.push({
|
||||
id: `${item.id}-output-${output.id}`,
|
||||
type: 'invitation_output',
|
||||
label: output.valueSatoshis !== undefined ? `${output.valueSatoshis.toLocaleString()} sats` : 'Output',
|
||||
type: "invitation_output",
|
||||
label:
|
||||
output.valueSatoshis !== undefined
|
||||
? `${output.valueSatoshis.toLocaleString()} sats`
|
||||
: "Output",
|
||||
description: output.description,
|
||||
isNested: true,
|
||||
utxo: output,
|
||||
@@ -64,8 +86,11 @@ export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow
|
||||
|
||||
rows.push({
|
||||
id: item.id,
|
||||
type: 'utxo',
|
||||
label: item.valueSatoshis !== undefined ? `${item.valueSatoshis.toLocaleString()} sats` : 'UTXO',
|
||||
type: "utxo",
|
||||
label:
|
||||
item.valueSatoshis !== undefined
|
||||
? `${item.valueSatoshis.toLocaleString()} sats`
|
||||
: "UTXO",
|
||||
description: item.description,
|
||||
isNested: false,
|
||||
utxo: item,
|
||||
@@ -75,18 +100,21 @@ export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function getHistoryItemColorName(row: HistoryDisplayRow, isSelected: boolean = false): HistoryColorName {
|
||||
if (isSelected) return 'info';
|
||||
export function getHistoryItemColorName(
|
||||
row: HistoryDisplayRow,
|
||||
isSelected: boolean = false,
|
||||
): HistoryColorName {
|
||||
if (isSelected) return "info";
|
||||
switch (row.type) {
|
||||
case 'invitation':
|
||||
return 'text';
|
||||
case 'invitation_input':
|
||||
return 'error';
|
||||
case 'invitation_output':
|
||||
return 'success';
|
||||
case 'utxo':
|
||||
return row.utxo?.reserved ? 'warning' : 'success';
|
||||
case "invitation":
|
||||
return "text";
|
||||
case "invitation_input":
|
||||
return "error";
|
||||
case "invitation_output":
|
||||
return "success";
|
||||
case "utxo":
|
||||
return row.utxo?.reserved ? "warning" : "success";
|
||||
default:
|
||||
return 'text';
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { XOTemplate, XOTemplateTransactionOutput } from '@xo-cash/types';
|
||||
import type { Invitation } from '../services/invitation.js';
|
||||
import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types";
|
||||
import type { Invitation } from "../services/invitation.js";
|
||||
|
||||
export interface SelectableUtxoLike {
|
||||
outpointTransactionHash: string;
|
||||
@@ -16,14 +16,17 @@ export const hasMissingRequirements = (missingRequirements: {
|
||||
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)
|
||||
(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> => {
|
||||
export const isInvitationRequirementsComplete = async (
|
||||
invitation: Invitation,
|
||||
): Promise<boolean> => {
|
||||
const missingRequirements = await invitation.getMissingRequirements();
|
||||
return !hasMissingRequirements(missingRequirements);
|
||||
};
|
||||
@@ -34,7 +37,7 @@ export const resolveActionRoles = (
|
||||
rolesFromNavigation?: string[],
|
||||
): string[] => {
|
||||
if (rolesFromNavigation && rolesFromNavigation.length > 0) {
|
||||
return [ ...new Set(rolesFromNavigation) ];
|
||||
return [...new Set(rolesFromNavigation)];
|
||||
}
|
||||
|
||||
if (!template || !actionIdentifier) return [];
|
||||
@@ -43,7 +46,7 @@ export const resolveActionRoles = (
|
||||
.filter((entry) => entry.action === actionIdentifier)
|
||||
.map((entry) => entry.role);
|
||||
|
||||
return [ ...new Set(roleIds) ];
|
||||
return [...new Set(roleIds)];
|
||||
};
|
||||
|
||||
export const roleRequiresInputs = (
|
||||
@@ -61,26 +64,38 @@ export const roleRequiresInputs = (
|
||||
|
||||
// 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 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 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') {
|
||||
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 normalizeLockingBytecodeHex = (value: string): string =>
|
||||
value.trim().replace(/^0x/i, "");
|
||||
|
||||
export const resolveProvidedLockingBytecodeHex = (
|
||||
template: XOTemplate,
|
||||
@@ -88,18 +103,23 @@ export const resolveProvidedLockingBytecodeHex = (
|
||||
variableValues: Record<string, string>,
|
||||
): string | undefined => {
|
||||
const outputDefinition = template.outputs?.[outputIdentifier];
|
||||
if (!outputDefinition || typeof outputDefinition.lockscript !== 'string') return undefined;
|
||||
if (!outputDefinition || typeof outputDefinition.lockscript !== "string")
|
||||
return undefined;
|
||||
|
||||
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDefinition.lockscript] as
|
||||
| { lockingScript?: string }
|
||||
| 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 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*>$/);
|
||||
const directVariableMatch = scriptExpression.match(
|
||||
/^<\s*([A-Za-z0-9_]+)\s*>$/,
|
||||
);
|
||||
if (!directVariableMatch) return undefined;
|
||||
|
||||
const variableIdentifier = directVariableMatch[1];
|
||||
@@ -111,15 +131,17 @@ export const resolveProvidedLockingBytecodeHex = (
|
||||
return normalizeLockingBytecodeHex(providedValue);
|
||||
};
|
||||
|
||||
export const mapUnspentOutputsToSelectable = (unspentOutputs: any[]): SelectableUtxoLike[] => {
|
||||
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'
|
||||
? typeof utxo.lockingBytecode === "string"
|
||||
? utxo.lockingBytecode
|
||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||
: Buffer.from(utxo.lockingBytecode).toString("hex")
|
||||
: undefined,
|
||||
selected: false,
|
||||
}));
|
||||
@@ -133,7 +155,10 @@ export const autoSelectGreedyUtxos = (
|
||||
const seenLockingBytecodes = new Set<string>();
|
||||
|
||||
for (const utxo of utxos) {
|
||||
if (utxo.lockingBytecode && seenLockingBytecodes.has(utxo.lockingBytecode)) {
|
||||
if (
|
||||
utxo.lockingBytecode &&
|
||||
seenLockingBytecodes.has(utxo.lockingBytecode)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (utxo.lockingBytecode) {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* Invitation utility functions.
|
||||
*
|
||||
*
|
||||
* Pure functions for parsing and formatting invitation data.
|
||||
* These functions have no React dependencies and can be used
|
||||
* in both TUI and CLI contexts.
|
||||
*/
|
||||
|
||||
import type { Invitation } from '../services/invitation.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import type { Invitation } from "../services/invitation.js";
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
|
||||
/**
|
||||
* Color names for invitation states.
|
||||
* These are semantic color names that can be mapped to actual colors
|
||||
* by the consuming application (TUI or CLI).
|
||||
*/
|
||||
export type StateColorName = 'info' | 'warning' | 'success' | 'error' | 'muted';
|
||||
export type StateColorName = "info" | "warning" | "success" | "error" | "muted";
|
||||
|
||||
/**
|
||||
* Input data extracted from invitation commits.
|
||||
@@ -61,7 +61,7 @@ export interface FormattedInvitationItem {
|
||||
|
||||
/**
|
||||
* Get the current state/status of an invitation.
|
||||
*
|
||||
*
|
||||
* @param invitation - The invitation to get state for
|
||||
* @returns The status string
|
||||
*/
|
||||
@@ -71,34 +71,34 @@ export function getInvitationState(invitation: Invitation): string {
|
||||
|
||||
/**
|
||||
* Get the semantic color name for an invitation state.
|
||||
*
|
||||
*
|
||||
* @param state - The invitation state string
|
||||
* @returns A semantic color name
|
||||
*/
|
||||
export function getStateColorName(state: string): StateColorName {
|
||||
switch (state) {
|
||||
case 'created':
|
||||
case 'published':
|
||||
return 'info';
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
case 'ready':
|
||||
case 'signed':
|
||||
case 'complete':
|
||||
case 'broadcast':
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'expired':
|
||||
case 'error':
|
||||
return 'error';
|
||||
case "created":
|
||||
case "published":
|
||||
return "info";
|
||||
case "pending":
|
||||
return "warning";
|
||||
case "ready":
|
||||
case "signed":
|
||||
case "complete":
|
||||
case "broadcast":
|
||||
case "completed":
|
||||
return "success";
|
||||
case "expired":
|
||||
case "error":
|
||||
return "error";
|
||||
default:
|
||||
return 'muted';
|
||||
return "muted";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all inputs from invitation commits.
|
||||
*
|
||||
*
|
||||
* @param invitation - The invitation to extract inputs from
|
||||
* @returns Array of input data
|
||||
*/
|
||||
@@ -118,11 +118,13 @@ export function getInvitationInputs(invitation: Invitation): InvitationInput[] {
|
||||
|
||||
/**
|
||||
* Extract all outputs from invitation commits.
|
||||
*
|
||||
*
|
||||
* @param invitation - The invitation to extract outputs from
|
||||
* @returns Array of output data
|
||||
*/
|
||||
export function getInvitationOutputs(invitation: Invitation): InvitationOutput[] {
|
||||
export function getInvitationOutputs(
|
||||
invitation: Invitation,
|
||||
): InvitationOutput[] {
|
||||
const outputs: InvitationOutput[] = [];
|
||||
for (const commit of invitation.data.commits || []) {
|
||||
for (const output of commit.data?.outputs || []) {
|
||||
@@ -139,11 +141,13 @@ export function getInvitationOutputs(invitation: Invitation): InvitationOutput[]
|
||||
|
||||
/**
|
||||
* Extract all variables from invitation commits.
|
||||
*
|
||||
*
|
||||
* @param invitation - The invitation to extract variables from
|
||||
* @returns Array of variable data
|
||||
*/
|
||||
export function getInvitationVariables(invitation: Invitation): InvitationVariable[] {
|
||||
export function getInvitationVariables(
|
||||
invitation: Invitation,
|
||||
): InvitationVariable[] {
|
||||
const variables: InvitationVariable[] = [];
|
||||
for (const commit of invitation.data.commits || []) {
|
||||
for (const variable of commit.data?.variables || []) {
|
||||
@@ -160,14 +164,17 @@ export function getInvitationVariables(invitation: Invitation): InvitationVariab
|
||||
|
||||
/**
|
||||
* Get the user's role from commits (the role they have accepted).
|
||||
*
|
||||
*
|
||||
* @param invitation - The invitation to check
|
||||
* @param userEntityId - The user's entity identifier
|
||||
* @returns The role identifier if found, null otherwise
|
||||
*/
|
||||
export function getUserRole(invitation: Invitation, userEntityId: string | null): string | null {
|
||||
export function getUserRole(
|
||||
invitation: Invitation,
|
||||
userEntityId: string | null,
|
||||
): string | null {
|
||||
if (!userEntityId) return null;
|
||||
|
||||
|
||||
for (const commit of invitation.data.commits || []) {
|
||||
if (commit.entityIdentifier === userEntityId) {
|
||||
// Check inputs for role
|
||||
@@ -189,32 +196,32 @@ export function getUserRole(invitation: Invitation, userEntityId: string | null)
|
||||
|
||||
/**
|
||||
* Format an invitation for display in a list.
|
||||
*
|
||||
*
|
||||
* @param invitation - The invitation to format
|
||||
* @param template - Optional template for additional info (name)
|
||||
* @returns Formatted item data for display
|
||||
*/
|
||||
export function formatInvitationListItem(
|
||||
invitation: Invitation,
|
||||
template?: XOTemplate | null
|
||||
template?: XOTemplate | null,
|
||||
): FormattedInvitationItem {
|
||||
// Validate that we have the minimum required data
|
||||
const invitationId = invitation?.data?.invitationIdentifier;
|
||||
const actionId = invitation?.data?.actionIdentifier;
|
||||
|
||||
|
||||
if (!invitationId || !actionId) {
|
||||
return {
|
||||
label: '',
|
||||
status: 'error',
|
||||
statusColor: 'error',
|
||||
label: "",
|
||||
status: "error",
|
||||
statusColor: "error",
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
|
||||
const state = getInvitationState(invitation);
|
||||
const templateName = template?.name ?? 'Unknown';
|
||||
const templateName = template?.name ?? "Unknown";
|
||||
const shortId = formatInvitationId(invitationId, 8);
|
||||
|
||||
|
||||
return {
|
||||
label: `[${state}] ${templateName}-${actionId} (${shortId})`,
|
||||
status: state,
|
||||
@@ -225,7 +232,7 @@ export function formatInvitationListItem(
|
||||
|
||||
/**
|
||||
* Format an invitation ID for display (truncated).
|
||||
*
|
||||
*
|
||||
* @param id - The full invitation ID
|
||||
* @param maxLength - Maximum length for display
|
||||
* @returns Truncated ID string
|
||||
@@ -238,7 +245,7 @@ export function formatInvitationId(id: string, maxLength: number = 16): string {
|
||||
|
||||
/**
|
||||
* Get all unique entity identifiers from an invitation's commits.
|
||||
*
|
||||
*
|
||||
* @param invitation - The invitation to check
|
||||
* @returns Array of unique entity identifiers
|
||||
*/
|
||||
@@ -254,12 +261,15 @@ export function getInvitationParticipants(invitation: Invitation): string[] {
|
||||
|
||||
/**
|
||||
* Check if a user is a participant in an invitation.
|
||||
*
|
||||
*
|
||||
* @param invitation - The invitation to check
|
||||
* @param userEntityId - The user's entity identifier
|
||||
* @returns True if the user has made at least one commit
|
||||
*/
|
||||
export function isUserParticipant(invitation: Invitation, userEntityId: string | null): boolean {
|
||||
export function isUserParticipant(
|
||||
invitation: Invitation,
|
||||
userEntityId: string | null,
|
||||
): boolean {
|
||||
if (!userEntityId) return false;
|
||||
return getInvitationParticipants(invitation).includes(userEntityId);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ export class Logger {
|
||||
private readonly path: string,
|
||||
) {}
|
||||
|
||||
send(level: 'log' | 'error' | 'warn' | 'info', message: string, ...metadata: unknown[]) {
|
||||
send(
|
||||
level: "log" | "error" | "warn" | "info",
|
||||
message: string,
|
||||
...metadata: unknown[]
|
||||
) {
|
||||
const data = {
|
||||
level,
|
||||
message: `${this.path}: ${message}`,
|
||||
@@ -13,34 +17,34 @@ export class Logger {
|
||||
};
|
||||
|
||||
fetch(`${this.endpoint}`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': this.token,
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": this.token,
|
||||
},
|
||||
}).catch(error => {
|
||||
console.error('Failed to send log to logger:', error);
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send log to logger:", error);
|
||||
});
|
||||
}
|
||||
|
||||
log(message: string, ...metadata: unknown[]) {
|
||||
this.send('log', message, ...metadata);
|
||||
this.send("log", message, ...metadata);
|
||||
}
|
||||
|
||||
error(message: string, ...metadata: unknown[]) {
|
||||
this.send('error', message, ...metadata);
|
||||
this.send("error", message, ...metadata);
|
||||
}
|
||||
|
||||
warn(message: string, ...metadata: unknown[]) {
|
||||
this.send('warn', message, ...metadata);
|
||||
this.send("warn", message, ...metadata);
|
||||
}
|
||||
|
||||
info(message: string, ...metadata: unknown[]) {
|
||||
this.send('info', message, ...metadata);
|
||||
this.send("info", message, ...metadata);
|
||||
}
|
||||
|
||||
child(path: string): Logger {
|
||||
return new Logger(this.endpoint, this.token, `${this.path}.${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { ExponentialBackoff } from './exponential-backoff.js';
|
||||
import { ExponentialBackoff } from "./exponential-backoff.js";
|
||||
|
||||
// Type declarations for browser environment (not available in Node.js)
|
||||
declare const document: {
|
||||
visibilityState: 'visible' | 'hidden';
|
||||
addEventListener: (event: string, handler: (event: Event) => void) => void;
|
||||
removeEventListener: (event: string, handler: (event: Event) => void) => void;
|
||||
} | undefined;
|
||||
declare const document:
|
||||
| {
|
||||
visibilityState: "visible" | "hidden";
|
||||
addEventListener: (
|
||||
event: string,
|
||||
handler: (event: Event) => void,
|
||||
) => void;
|
||||
removeEventListener: (
|
||||
event: string,
|
||||
handler: (event: Event) => void,
|
||||
) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* A Server-Sent Events client implementation using fetch API.
|
||||
@@ -51,14 +59,14 @@ export class SSESession {
|
||||
this.options = {
|
||||
// Use default fetch function.
|
||||
fetch: (...args) => fetch(...args),
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Accept: "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
onConnected: () => {},
|
||||
onMessage: () => {},
|
||||
onError: (error) => console.error('SSESession error:', error),
|
||||
onError: (error) => console.error("SSESession error:", error),
|
||||
onDisconnected: () => {},
|
||||
onReconnect: (options) => Promise.resolve(options),
|
||||
|
||||
@@ -71,10 +79,10 @@ export class SSESession {
|
||||
this.controller = new AbortController();
|
||||
|
||||
// Set up visibility change handling if in mobile browser environment
|
||||
if (typeof document !== 'undefined') {
|
||||
if (typeof document !== "undefined") {
|
||||
this.visibilityChangeHandler = this.handleVisibilityChange.bind(this);
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
"visibilitychange",
|
||||
this.visibilityChangeHandler,
|
||||
);
|
||||
}
|
||||
@@ -85,16 +93,16 @@ export class SSESession {
|
||||
*/
|
||||
private async handleVisibilityChange(): Promise<void> {
|
||||
// Guard for Node.js environment where document is undefined
|
||||
if (typeof document === 'undefined') return;
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
// When going to background, close the current connection cleanly
|
||||
// This allows us to reconnect mobile devices when they come back after leaving the tab or browser app.
|
||||
if (document.visibilityState === 'hidden') {
|
||||
if (document.visibilityState === "hidden") {
|
||||
this.controller.abort();
|
||||
}
|
||||
|
||||
// When coming back to foreground, attempt to reconnect if not connected
|
||||
if (document.visibilityState === 'visible' && !this.connected) {
|
||||
if (document.visibilityState === "visible" && !this.connected) {
|
||||
await this.connect();
|
||||
}
|
||||
}
|
||||
@@ -115,7 +123,7 @@ export class SSESession {
|
||||
headers: headers || {},
|
||||
body: body || null,
|
||||
signal: this.controller.signal,
|
||||
cache: 'no-store',
|
||||
cache: "no-store",
|
||||
};
|
||||
|
||||
const exponentialBackoff = ExponentialBackoff.from({
|
||||
@@ -144,7 +152,7 @@ export class SSESession {
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
throw new Error('Response body is null');
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
|
||||
return res.body.getReader();
|
||||
@@ -228,10 +236,10 @@ export class SSESession {
|
||||
const line = lines[i];
|
||||
|
||||
// Empty line signals the end of an event
|
||||
if (line === '') {
|
||||
if (line === "") {
|
||||
if (currentEvent.data) {
|
||||
// Remove trailing newline if present
|
||||
currentEvent.data = currentEvent.data.replace(/\n$/, '');
|
||||
currentEvent.data = currentEvent.data.replace(/\n$/, "");
|
||||
events.push(currentEvent as SSEvent);
|
||||
currentEvent = {};
|
||||
completeEventCount = i + 1;
|
||||
@@ -242,24 +250,24 @@ export class SSESession {
|
||||
if (!line) continue;
|
||||
|
||||
// Parse field: value format
|
||||
const colonIndex = line.indexOf(':');
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
const field = line.slice(0, colonIndex);
|
||||
// Skip initial space after colon if present
|
||||
const valueStartIndex =
|
||||
colonIndex + 1 + (line[colonIndex + 1] === ' ' ? 1 : 0);
|
||||
colonIndex + 1 + (line[colonIndex + 1] === " " ? 1 : 0);
|
||||
const value = line.slice(valueStartIndex);
|
||||
|
||||
if (field === 'data') {
|
||||
if (field === "data") {
|
||||
currentEvent.data = currentEvent.data
|
||||
? currentEvent.data + '\n' + value
|
||||
? currentEvent.data + "\n" + value
|
||||
: value;
|
||||
} else if (field === 'event') {
|
||||
} else if (field === "event") {
|
||||
currentEvent.event = value;
|
||||
} else if (field === 'id') {
|
||||
} else if (field === "id") {
|
||||
currentEvent.id = value;
|
||||
} else if (field === 'retry') {
|
||||
} else if (field === "retry") {
|
||||
const retryMs = parseInt(value, 10);
|
||||
if (!isNaN(retryMs)) {
|
||||
currentEvent.retry = retryMs;
|
||||
@@ -268,7 +276,7 @@ export class SSESession {
|
||||
}
|
||||
|
||||
// Store the remainder of the buffer for the next chunk
|
||||
const remainder = lines.slice(completeEventCount).join('\n');
|
||||
const remainder = lines.slice(completeEventCount).join("\n");
|
||||
this.messageBuffer = this.textEncoder.encode(remainder);
|
||||
|
||||
return events;
|
||||
@@ -291,9 +299,9 @@ export class SSESession {
|
||||
this.controller.abort();
|
||||
|
||||
// Remove the visibility handler (This is only required on browsers)
|
||||
if (this.visibilityChangeHandler && typeof document !== 'undefined') {
|
||||
if (this.visibilityChangeHandler && typeof document !== "undefined") {
|
||||
document.removeEventListener(
|
||||
'visibilitychange',
|
||||
"visibilitychange",
|
||||
this.visibilityChangeHandler,
|
||||
);
|
||||
this.visibilityChangeHandler = null;
|
||||
@@ -348,7 +356,7 @@ export interface SSESessionOptions {
|
||||
/**
|
||||
* HTTP method to use (GET or POST).
|
||||
*/
|
||||
method: 'GET' | 'POST';
|
||||
method: "GET" | "POST";
|
||||
|
||||
/**
|
||||
* HTTP headers to send with the request.
|
||||
|
||||
@@ -4,14 +4,17 @@ import { SSESession, type SSEvent } from "./sse-client.js";
|
||||
import { decodeExtendedJson, encodeExtendedJson } from "./ext-json.js";
|
||||
|
||||
export type SyncServerEventMap = {
|
||||
'connected': void;
|
||||
'disconnected': void;
|
||||
'error': Error;
|
||||
'message': SSEvent;
|
||||
}
|
||||
connected: void;
|
||||
disconnected: void;
|
||||
error: Error;
|
||||
message: SSEvent;
|
||||
};
|
||||
|
||||
export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
static async from(baseUrl: string, invitationIdentifier: string): Promise<SyncServer> {
|
||||
static async from(
|
||||
baseUrl: string,
|
||||
invitationIdentifier: string,
|
||||
): Promise<SyncServer> {
|
||||
const server = new SyncServer(baseUrl, invitationIdentifier);
|
||||
await server.connect();
|
||||
return server;
|
||||
@@ -19,22 +22,32 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
|
||||
private sse: SSESession;
|
||||
|
||||
constructor(private readonly baseUrl: string, private readonly invitationIdentifier: string) {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
private readonly invitationIdentifier: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
// Create an SSE Session
|
||||
this.sse = new SSESession(`${baseUrl}/invitations?invitationIdentifier=${invitationIdentifier}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
this.sse = new SSESession(
|
||||
`${baseUrl}/invitations?invitationIdentifier=${invitationIdentifier}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
|
||||
// Create our event bubblers
|
||||
onMessage: (event: SSEvent) => this.emit('message', event),
|
||||
onError: (error: unknown) => this.emit('error', error instanceof Error ? error : new Error(String(error))),
|
||||
onDisconnected: () => this.emit('disconnected', undefined),
|
||||
onConnected: () => this.emit('connected', undefined),
|
||||
});
|
||||
// Create our event bubblers
|
||||
onMessage: (event: SSEvent) => this.emit("message", event),
|
||||
onError: (error: unknown) =>
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
),
|
||||
onDisconnected: () => this.emit("disconnected", undefined),
|
||||
onConnected: () => this.emit("connected", undefined),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,13 +73,17 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
*/
|
||||
async getInvitation(identifier: string): Promise<XOInvitation | undefined> {
|
||||
// Send a GET request to the sync server
|
||||
const response = await fetch(`${this.baseUrl}/invitations?invitationIdentifier=${identifier}`);
|
||||
|
||||
if(!response.ok) {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/invitations?invitationIdentifier=${identifier}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const invitation = decodeExtendedJson(await response.text()) as XOInvitation | undefined;
|
||||
|
||||
const invitation = decodeExtendedJson(await response.text()) as
|
||||
| XOInvitation
|
||||
| undefined;
|
||||
return invitation;
|
||||
}
|
||||
|
||||
@@ -78,10 +95,10 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
async publishInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||
// Send a POST request to the sync server
|
||||
const response = await fetch(`${this.baseUrl}/invitations`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: encodeExtendedJson(invitation),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,4 +113,4 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Template utility functions.
|
||||
*
|
||||
*
|
||||
* Pure functions for parsing and formatting template data.
|
||||
* These functions have no React dependencies and can be used
|
||||
* in both TUI and CLI contexts.
|
||||
*/
|
||||
|
||||
import type { XOTemplate, XOTemplateAction } from '@xo-cash/types';
|
||||
import type { XOTemplate, XOTemplateAction } from "@xo-cash/types";
|
||||
|
||||
/**
|
||||
* Formatted template list item data.
|
||||
@@ -57,25 +57,25 @@ export interface TemplateRole {
|
||||
|
||||
/**
|
||||
* Format a template for display in a list.
|
||||
*
|
||||
*
|
||||
* @param template - The template to format
|
||||
* @param index - Optional index for numbered display
|
||||
* @returns Formatted item data for display
|
||||
*/
|
||||
export function formatTemplateListItem(
|
||||
template: XOTemplate | null | undefined,
|
||||
index?: number
|
||||
index?: number,
|
||||
): FormattedTemplateItem {
|
||||
if (!template) {
|
||||
return {
|
||||
label: '',
|
||||
label: "",
|
||||
description: undefined,
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
|
||||
const name = template.name || 'Unnamed Template';
|
||||
const prefix = index !== undefined ? `${index + 1}. ` : '';
|
||||
const name = template.name || "Unnamed Template";
|
||||
const prefix = index !== undefined ? `${index + 1}. ` : "";
|
||||
|
||||
return {
|
||||
label: `${prefix}${name}`,
|
||||
@@ -86,7 +86,7 @@ export function formatTemplateListItem(
|
||||
|
||||
/**
|
||||
* Format an action for display in a list.
|
||||
*
|
||||
*
|
||||
* @param actionId - The action identifier
|
||||
* @param action - The action definition from the template
|
||||
* @param roleCount - Number of roles that can start this action
|
||||
@@ -97,11 +97,11 @@ export function formatActionListItem(
|
||||
actionId: string,
|
||||
action: XOTemplateAction | null | undefined,
|
||||
roleCount: number = 1,
|
||||
index?: number
|
||||
index?: number,
|
||||
): FormattedActionItem {
|
||||
if (!actionId) {
|
||||
return {
|
||||
label: '',
|
||||
label: "",
|
||||
description: undefined,
|
||||
roleCount: 0,
|
||||
isValid: false,
|
||||
@@ -109,8 +109,8 @@ export function formatActionListItem(
|
||||
}
|
||||
|
||||
const name = action?.name || actionId;
|
||||
const prefix = index !== undefined ? `${index + 1}. ` : '';
|
||||
const roleSuffix = roleCount > 1 ? ` (${roleCount} roles)` : '';
|
||||
const prefix = index !== undefined ? `${index + 1}. ` : "";
|
||||
const roleSuffix = roleCount > 1 ? ` (${roleCount} roles)` : "";
|
||||
|
||||
return {
|
||||
label: `${prefix}${name}${roleSuffix}`,
|
||||
@@ -124,14 +124,14 @@ export function formatActionListItem(
|
||||
* Deduplicate starting actions from a template.
|
||||
* Multiple roles that can start the same action are counted
|
||||
* but returned as a single entry.
|
||||
*
|
||||
*
|
||||
* @param template - The template to process
|
||||
* @param startingActions - Array of { action, role } pairs
|
||||
* @returns Array of unique starting actions with role counts
|
||||
*/
|
||||
export function deduplicateStartingActions(
|
||||
template: XOTemplate,
|
||||
startingActions: Array<{ action: string; role: string }>
|
||||
startingActions: Array<{ action: string; role: string }>,
|
||||
): UniqueStartingAction[] {
|
||||
const actionMap = new Map<string, UniqueStartingAction>();
|
||||
|
||||
@@ -154,7 +154,7 @@ export function deduplicateStartingActions(
|
||||
|
||||
/**
|
||||
* Get all roles from a template.
|
||||
*
|
||||
*
|
||||
* @param template - The template to process
|
||||
* @returns Array of role information
|
||||
*/
|
||||
@@ -163,7 +163,7 @@ export function getTemplateRoles(template: XOTemplate): TemplateRole[] {
|
||||
|
||||
return Object.entries(template.roles).map(([roleId, role]) => {
|
||||
// Handle case where role might be a string instead of object
|
||||
const roleObj = typeof role === 'object' ? role : null;
|
||||
const roleObj = typeof role === "object" ? role : null;
|
||||
return {
|
||||
roleId,
|
||||
name: roleObj?.name || roleId,
|
||||
@@ -174,21 +174,22 @@ export function getTemplateRoles(template: XOTemplate): TemplateRole[] {
|
||||
|
||||
/**
|
||||
* Get roles that can start a specific action.
|
||||
*
|
||||
*
|
||||
* @param template - The template to check
|
||||
* @param actionIdentifier - The action to check
|
||||
* @returns Array of role information for roles that can start this action
|
||||
*/
|
||||
export function getRolesForAction(
|
||||
template: XOTemplate,
|
||||
actionIdentifier: string
|
||||
actionIdentifier: string,
|
||||
): TemplateRole[] {
|
||||
const startEntries = (template.start ?? [])
|
||||
.filter((s) => s.action === actionIdentifier);
|
||||
const startEntries = (template.start ?? []).filter(
|
||||
(s) => s.action === actionIdentifier,
|
||||
);
|
||||
|
||||
return startEntries.map((entry) => {
|
||||
const roleDef = template.roles?.[entry.role];
|
||||
const roleObj = typeof roleDef === 'object' ? roleDef : null;
|
||||
const roleObj = typeof roleDef === "object" ? roleDef : null;
|
||||
return {
|
||||
roleId: entry.role,
|
||||
name: roleObj?.name || entry.role,
|
||||
@@ -199,48 +200,52 @@ export function getRolesForAction(
|
||||
|
||||
/**
|
||||
* Get template name safely.
|
||||
*
|
||||
*
|
||||
* @param template - The template
|
||||
* @returns The template name or a default
|
||||
*/
|
||||
export function getTemplateName(template: XOTemplate | null | undefined): string {
|
||||
return template?.name || 'Unknown Template';
|
||||
export function getTemplateName(
|
||||
template: XOTemplate | null | undefined,
|
||||
): string {
|
||||
return template?.name || "Unknown Template";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template description safely.
|
||||
*
|
||||
*
|
||||
* @param template - The template
|
||||
* @returns The template description or undefined
|
||||
*/
|
||||
export function getTemplateDescription(template: XOTemplate | null | undefined): string | undefined {
|
||||
export function getTemplateDescription(
|
||||
template: XOTemplate | null | undefined,
|
||||
): string | undefined {
|
||||
return template?.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action name safely.
|
||||
*
|
||||
*
|
||||
* @param template - The template containing the action
|
||||
* @param actionId - The action identifier
|
||||
* @returns The action name or the action ID as fallback
|
||||
*/
|
||||
export function getActionName(
|
||||
template: XOTemplate | null | undefined,
|
||||
actionId: string
|
||||
actionId: string,
|
||||
): string {
|
||||
return template?.actions?.[actionId]?.name || actionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action description safely.
|
||||
*
|
||||
*
|
||||
* @param template - The template containing the action
|
||||
* @param actionId - The action identifier
|
||||
* @returns The action description or undefined
|
||||
*/
|
||||
export function getActionDescription(
|
||||
template: XOTemplate | null | undefined,
|
||||
actionId: string
|
||||
actionId: string,
|
||||
): string | undefined {
|
||||
return template?.actions?.[actionId]?.description;
|
||||
}
|
||||
|
||||
@@ -2,19 +2,19 @@ import type { XOTemplate } from "@xo-cash/types";
|
||||
|
||||
/**
|
||||
* List of helper functions to make templates easy to develop with.
|
||||
*
|
||||
*
|
||||
* Most of these are centered around the fact that the templates are very disjointed and sporadic in where the information lies.
|
||||
*
|
||||
*
|
||||
* I.e. required variables ** names ** are stored in actions.roles.requirements.variables, but the variable definitions are stored in the template.variables object.
|
||||
* so to make a UI out of that, you first need to iterate over the actions.roles.requirements.variables and then lookup the variable definition in the template.variables object.
|
||||
* this is a pain, so these functions are here to help.
|
||||
*
|
||||
*
|
||||
* Simiarly for inputs, outputs, locking scripts, etc. The get referenced in the actions, but then the actual lookup of what is actually is becomes a pain.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deepens a templates object by append the actual definitions for the objects as they are referenced in the template.
|
||||
* NOTE: Whether this is better as part of the template or not can be debated endlessly.
|
||||
* NOTE: Whether this is better as part of the template or not can be debated endlessly.
|
||||
* The decision for separating the defintions from where they are used is likely to reduce template size...
|
||||
* This could be fruitless though, as its easily compressible (gzip, msgpack, etc) will yield similar results to the separated approach.
|
||||
*/
|
||||
@@ -222,7 +222,14 @@ export function resolveTemplateReferences(
|
||||
const resolved = structuredClone(template);
|
||||
|
||||
for (const rule of RESOLUTION_RULES) {
|
||||
applyRule(resolved, resolved, rule.path.split("."), 0, rule.from, rule.mode);
|
||||
applyRule(
|
||||
resolved,
|
||||
resolved,
|
||||
rule.path.split("."),
|
||||
0,
|
||||
rule.from,
|
||||
rule.mode,
|
||||
);
|
||||
}
|
||||
|
||||
return resolved as unknown as ResolvedXOTemplate;
|
||||
@@ -357,20 +364,14 @@ interface ResolvedStartEntry {
|
||||
|
||||
// ─── The full resolved template ──────────────────────────────────
|
||||
|
||||
interface ResolvedXOTemplate
|
||||
extends Omit<
|
||||
XOTemplate,
|
||||
| "actions"
|
||||
| "transactions"
|
||||
| "outputs"
|
||||
| "inputs"
|
||||
| "lockingScripts"
|
||||
| "start"
|
||||
> {
|
||||
interface ResolvedXOTemplate extends Omit<
|
||||
XOTemplate,
|
||||
"actions" | "transactions" | "outputs" | "inputs" | "lockingScripts" | "start"
|
||||
> {
|
||||
start: ResolvedStartEntry[];
|
||||
actions: Record<string, ResolvedActionDefinition>;
|
||||
transactions: Record<string, ResolvedTransactionDefinition>;
|
||||
outputs: Record<string, ResolvedOutputDefinition>;
|
||||
inputs: Record<string, ResolvedInputDefinition>;
|
||||
lockingScripts: Record<string, ResolvedLockingScriptDefinition>;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user