Compare commits
4 Commits
f1ac89ef91
...
c7e1d69e2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
c7e1d69e2d
|
|||
|
b30243f674
|
|||
|
5e9c6db412
|
|||
|
5bec49858f
|
@@ -12,6 +12,7 @@
|
||||
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||
"build": "tsc && npm run build:copy-scripts",
|
||||
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
|
||||
"build:unsafe": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts",
|
||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
|
||||
10
readme.md
10
readme.md
@@ -3,6 +3,7 @@
|
||||
## Installation
|
||||
|
||||
### Full Installation
|
||||
|
||||
```bash
|
||||
# Create a new directory since we are going to be pulling in engine too
|
||||
mkdir xo-terminal && cd xo-terminal
|
||||
@@ -126,28 +127,37 @@ npm install -g .
|
||||
|
||||
### Install autocomplete completions (From the xo-cli directory)
|
||||
|
||||
These commands add `XO_CONFIG_DIR` to your shell config with a default of
|
||||
`~/.config/xo-cli`. Set it to an absolute path before installing, or edit the
|
||||
generated assignment, to use a different wallet-state directory.
|
||||
|
||||
#### Install for bash
|
||||
|
||||
```bash
|
||||
npm run autocomplete:install:bash
|
||||
```
|
||||
|
||||
#### Install for zsh
|
||||
|
||||
```bash
|
||||
npm run autocomplete:install:zsh
|
||||
```
|
||||
|
||||
#### Install for fish
|
||||
|
||||
```bash
|
||||
npm run autocomplete:install:fish
|
||||
```
|
||||
|
||||
### Run the CLI
|
||||
|
||||
```bash
|
||||
# If globally installed (Not really usable if not globally installed)
|
||||
xo-cli
|
||||
```
|
||||
|
||||
### Run the TUI
|
||||
|
||||
```bash
|
||||
# If globally installed
|
||||
xo-tui
|
||||
|
||||
@@ -9,13 +9,13 @@ There are two global commands after install:
|
||||
|
||||
## Global config directory
|
||||
|
||||
Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run commands from any directory:
|
||||
Wallet state lives under **`${XO_CONFIG_DIR:-~/.config/xo-cli}`**, so you can run commands from any directory. Set `XO_CONFIG_DIR` to use a different wallet-state root.
|
||||
|
||||
| Path | Purpose |
|
||||
| ----------------------------- | ----------------------------------------------------------------------- |
|
||||
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) |
|
||||
| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
|
||||
| `~/.config/xo-cli/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
|
||||
| Path | Purpose |
|
||||
| --------------------------- | ----------------------------------------------------------------------- |
|
||||
| `$XO_CONFIG_DIR/mnemonics/` | Mnemonic files (`mnemonic-*`) |
|
||||
| `$XO_CONFIG_DIR/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
|
||||
| `$XO_CONFIG_DIR/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
|
||||
|
||||
**Local to your shell’s current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
|
||||
|
||||
@@ -39,21 +39,24 @@ npx tsx src/cli/index.ts <command> [options]
|
||||
npx tsx src/index.ts # TUI
|
||||
```
|
||||
|
||||
### Environment variables (TUI / `xo-tui`)
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default |
|
||||
| ------------------------- | ----------------------------------------- |
|
||||
| `SYNC_SERVER_URL` | `http://localhost:3000` |
|
||||
| `DB_PATH` | `~/.config/xo-cli/data` |
|
||||
| `DB_FILENAME` | `xo-wallet.db` |
|
||||
| `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.db` |
|
||||
| Variable | Default |
|
||||
| ------------------------- | --------------------------------------- |
|
||||
| `XO_CONFIG_DIR` | `~/.config/xo-cli` |
|
||||
| `SYNC_SERVER_URL` | `http://localhost:3000` |
|
||||
| `DB_PATH` | `$XO_CONFIG_DIR/data` |
|
||||
| `DB_FILENAME` | `xo-wallet.db` |
|
||||
| `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` |
|
||||
|
||||
Use an absolute path for a custom root. Setting `XO_CONFIG_DIR` does not copy state from the default directory.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Wallet Setup
|
||||
|
||||
```bash
|
||||
# Generate a new mnemonic (saved under ~/.config/xo-cli/mnemonics/)
|
||||
# Generate a new mnemonic (saved under $XO_CONFIG_DIR/mnemonics/)
|
||||
xo-cli mnemonic create
|
||||
|
||||
# Import an existing mnemonic seed phrase
|
||||
@@ -68,7 +71,7 @@ xo-cli mnemonic list
|
||||
### Wallet Persistence
|
||||
|
||||
The first time you pass `-m <name>`, that reference is saved as
|
||||
`default-mnemonic` in `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
|
||||
`default-mnemonic` in `$XO_CONFIG_DIR/.wallet`. Later runs can omit `-m`.
|
||||
|
||||
`currency` controls the fiat unit used when showing BCH/sats conversions in the TUI.
|
||||
|
||||
@@ -76,7 +79,7 @@ Mnemonic resolution order:
|
||||
|
||||
1. Absolute path, if the file exists
|
||||
2. Path relative to the current working directory
|
||||
3. `~/.config/xo-cli/mnemonics/<basename>`
|
||||
3. `$XO_CONFIG_DIR/mnemonics/<basename>`
|
||||
|
||||
```bash
|
||||
xo-cli resource list -m mnemonic-nuclear
|
||||
@@ -85,15 +88,15 @@ xo-cli resource list
|
||||
|
||||
## Global Options (`xo-cli`)
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------------ | --------------------------------------------------- |
|
||||
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
|
||||
| Flag | Description |
|
||||
| ------------------------------ | ---------------------------------------------------- |
|
||||
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
|
||||
| `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) |
|
||||
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
|
||||
| `-v`, `--verbose` | Verbose output |
|
||||
| `-h`, `--help` | Help |
|
||||
| `-v`, `--verbose` | Verbose output |
|
||||
| `-h`, `--help` | Help |
|
||||
|
||||
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `~/.config/xo-cli/data/` (see `src/cli/index.ts`).
|
||||
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `$XO_CONFIG_DIR/data/` (see `src/cli/index.ts`).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -201,9 +204,11 @@ eval "$(xo-cli completions zsh)"
|
||||
xo-cli completions fish | source
|
||||
```
|
||||
|
||||
`xo-cli completions <shell> --install` adds a default `XO_CONFIG_DIR` assignment to the shell startup file if one is not already present. Mnemonic aliases are completed directly from `$XO_CONFIG_DIR/mnemonics/`; database-backed suggestions still use `xo-complete`.
|
||||
|
||||
## File Conventions
|
||||
|
||||
| Location | Purpose |
|
||||
| ------------------- | ------------------------------------------ |
|
||||
| `~/.config/xo-cli/` | Global wallet state |
|
||||
| `./` (cwd) | Templates, invitation JSON, explicit paths |
|
||||
| Location | Purpose |
|
||||
| ---------------- | ------------------------------------------ |
|
||||
| `$XO_CONFIG_DIR` | Global wallet state |
|
||||
| `./` (cwd) | Templates, invitation JSON, explicit paths |
|
||||
|
||||
@@ -19,11 +19,7 @@
|
||||
* xo-cli completions fish --install
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
appendFileSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir } from "node:os";
|
||||
@@ -193,7 +189,7 @@ export function generateFishCompletions(binName: string): string {
|
||||
return loadAndProcessTemplate("fish.fish", binName);
|
||||
}
|
||||
|
||||
type ShellType = "bash" | "zsh" | "fish";
|
||||
export type ShellType = "bash" | "zsh" | "fish";
|
||||
|
||||
const generators: Record<ShellType, (binName: string) => string> = {
|
||||
bash: generateBashCompletions,
|
||||
@@ -202,51 +198,76 @@ const generators: Record<ShellType, (binName: string) => string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Shell config file paths and eval commands for each shell type.
|
||||
* Shell config file paths and startup commands for each shell type.
|
||||
*/
|
||||
const shellConfigs: Record<
|
||||
ShellType,
|
||||
{ configFile: string; evalCommand: (binName: string) => string }
|
||||
{
|
||||
configFile: string;
|
||||
configDirCommand: string;
|
||||
configDirPattern: RegExp;
|
||||
evalCommand: (binName: string) => string;
|
||||
}
|
||||
> = {
|
||||
bash: {
|
||||
configFile: join(homedir(), ".bashrc"),
|
||||
configDirCommand:
|
||||
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
|
||||
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
|
||||
evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
|
||||
},
|
||||
zsh: {
|
||||
configFile: join(homedir(), ".zshrc"),
|
||||
configDirCommand:
|
||||
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
|
||||
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
|
||||
evalCommand: (binName) => `eval "$(${binName} completions zsh)"`,
|
||||
},
|
||||
fish: {
|
||||
configFile: join(homedir(), ".config", "fish", "config.fish"),
|
||||
configDirCommand:
|
||||
'set -q XO_CONFIG_DIR; or set -gx XO_CONFIG_DIR "$HOME/.config/xo-cli"',
|
||||
configDirPattern: /^\s*set\b[^\n]*\bXO_CONFIG_DIR\b/m,
|
||||
evalCommand: (binName) => `${binName} completions fish | source`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs completions to the user's shell config file.
|
||||
* Adds the eval command if not already present.
|
||||
* Adds a default config directory and the eval command if not already present.
|
||||
* @param shell - The shell type
|
||||
* @param binName - The CLI binary name
|
||||
* @returns true if installed, false if already present
|
||||
*/
|
||||
function installCompletions(shell: ShellType, binName: string): boolean {
|
||||
const config = shellConfigs[shell];
|
||||
export function installCompletions(
|
||||
shell: ShellType,
|
||||
binName: string,
|
||||
configFile: string = shellConfigs[shell].configFile,
|
||||
): boolean {
|
||||
const config = { ...shellConfigs[shell], configFile };
|
||||
const evalCommand = config.evalCommand(binName);
|
||||
|
||||
// Check if config file exists and already has the completion line
|
||||
let existingContent = "";
|
||||
if (existsSync(config.configFile)) {
|
||||
existingContent = readFileSync(config.configFile, "utf8");
|
||||
if (existingContent.includes(evalCommand)) {
|
||||
return false; // Already installed
|
||||
}
|
||||
}
|
||||
|
||||
// Append the completion line
|
||||
const commands: string[] = [];
|
||||
if (!config.configDirPattern.test(existingContent)) {
|
||||
commands.push(config.configDirCommand);
|
||||
}
|
||||
if (!existingContent.includes(evalCommand)) {
|
||||
commands.push(evalCommand);
|
||||
}
|
||||
if (commands.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const newLine =
|
||||
existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
|
||||
const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\n`;
|
||||
const completionBlock = `${newLine}\n# ${binName} shell completions\n${commands.join("\n")}\n`;
|
||||
|
||||
mkdirSync(dirname(config.configFile), { recursive: true });
|
||||
appendFileSync(config.configFile, completionBlock);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,19 @@ __xo_complete() {
|
||||
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
# @description
|
||||
# Lists mnemonic aliases directly from the config directory without starting
|
||||
# the dynamic Node helper.
|
||||
__xo_complete_mnemonics() {
|
||||
local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"
|
||||
local file mnemonic
|
||||
for file in "${config_dir}"/mnemonics/mnemonic-*; do
|
||||
[[ -f "${file}" ]] || continue
|
||||
mnemonic="${file##*/}"
|
||||
[[ "${mnemonic}" == "$1"* ]] && printf '%s\n' "${mnemonic}"
|
||||
done
|
||||
}
|
||||
|
||||
# @description
|
||||
# Main completion dispatcher invoked by bash's `complete -F`.
|
||||
# It determines context (command/subcommand/argument position) and then mixes:
|
||||
@@ -39,10 +52,10 @@ _{{FUNC_NAME}}_completions() {
|
||||
_init_completion || return
|
||||
|
||||
# If the previous token is `-m/--mnemonic-file`, this argument expects a
|
||||
# mnemonic file alias/path. Ask the helper for mnemonic suggestions.
|
||||
# mnemonic file alias/path. List mnemonic aliases directly from disk.
|
||||
if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then
|
||||
local mnemonics
|
||||
mnemonics=$(__xo_complete mnemonics "${cur}")
|
||||
mnemonics=$(__xo_complete_mnemonics "${cur}")
|
||||
if [[ -n "${mnemonics}" ]]; then
|
||||
while IFS= read -r line; do
|
||||
COMPREPLY+=("$line")
|
||||
|
||||
@@ -28,6 +28,21 @@ function __{{FUNC_NAME}}_complete_dynamic
|
||||
end
|
||||
end
|
||||
|
||||
# @description
|
||||
# Lists mnemonic aliases directly from the config directory without starting
|
||||
# the dynamic Node helper.
|
||||
function __{{FUNC_NAME}}_complete_mnemonics
|
||||
set -l config_dir "$XO_CONFIG_DIR"
|
||||
if test -z "$config_dir"
|
||||
set config_dir "$HOME/.config/xo-cli"
|
||||
end
|
||||
for file in $config_dir/mnemonics/mnemonic-*
|
||||
if test -f "$file"
|
||||
string replace -r '.*/' '' "$file"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Global option flags available across top-level command contexts.
|
||||
complete -c {{BIN_NAME}} -s h -d "Show help"
|
||||
complete -c {{BIN_NAME}} -l help -d "Show help"
|
||||
@@ -37,8 +52,8 @@ complete -c {{BIN_NAME}} -s o -d "Output file"
|
||||
complete -c {{BIN_NAME}} -l output -d "Output file"
|
||||
complete -c {{BIN_NAME}} -l currency -d "Set fiat display currency"
|
||||
|
||||
# Dynamic completion for `-m/--mnemonic-file`.
|
||||
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
|
||||
# Shell-native completion for `-m/--mnemonic-file`.
|
||||
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_mnemonics)'
|
||||
|
||||
# Top-level command registrations inserted by template expansion.
|
||||
{{TOP_LEVEL_COMMANDS}}
|
||||
|
||||
@@ -25,6 +25,19 @@ __xo_complete() {
|
||||
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
# @description
|
||||
# Lists mnemonic aliases directly from the config directory without starting
|
||||
# the dynamic Node helper.
|
||||
__xo_complete_mnemonics() {
|
||||
local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"
|
||||
local file mnemonic
|
||||
for file in "${config_dir}"/mnemonics/mnemonic-*(N); do
|
||||
[[ -f "${file}" ]] || continue
|
||||
mnemonic="${file:t}"
|
||||
[[ "${mnemonic}" == "$1"* ]] && print -r -- "${mnemonic}"
|
||||
done
|
||||
}
|
||||
|
||||
# @description
|
||||
# Main zsh completion dispatcher registered via `compdef`.
|
||||
# It resolves command context from `$words`/`$CURRENT` and serves:
|
||||
@@ -38,7 +51,7 @@ _{{FUNC_NAME}}_completions() {
|
||||
# If previous token is `-m/--mnemonic-file`, complete mnemonic sources.
|
||||
if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
|
||||
local mnemonics
|
||||
mnemonics=("${(@f)$(__xo_complete mnemonics "${words[CURRENT]}")}")
|
||||
mnemonics=("${(@f)$(__xo_complete_mnemonics "${words[CURRENT]}")}")
|
||||
if [[ ${#mnemonics[@]} -gt 0 ]]; then
|
||||
compadd -- "${mnemonics[@]}"
|
||||
return
|
||||
|
||||
@@ -23,7 +23,10 @@ const DUST_THRESHOLD = 546n;
|
||||
/**
|
||||
* Serializes an invitation to pretty-printed JSON for file export.
|
||||
*/
|
||||
const formatInvitationForFile = (invitation: XOInvitation, indent = 2): string =>
|
||||
const formatInvitationForFile = (
|
||||
invitation: XOInvitation,
|
||||
indent = 2,
|
||||
): string =>
|
||||
JSON.stringify(JSON.parse(serializeInvitation(invitation)), null, indent);
|
||||
|
||||
/**
|
||||
@@ -358,8 +361,7 @@ export const handleInvitationExportCommand = async (
|
||||
}
|
||||
|
||||
const invitation = deps.app.invitations.find(
|
||||
(candidate) =>
|
||||
candidate.data.invitationIdentifier === invitationIdentifier,
|
||||
(candidate) => candidate.data.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
|
||||
if (!invitation) {
|
||||
@@ -499,7 +501,9 @@ export const handleInvitationCommand = async (
|
||||
hasMissingRequirements(missingRequirements.templateRequirements) ||
|
||||
missingRequirements.inputsMissingSignatures.length > 0;
|
||||
|
||||
deps.io.verbose(`Missing requirements: ${formatObject(missingRequirements)}`);
|
||||
deps.io.verbose(
|
||||
`Missing requirements: ${formatObject(missingRequirements)}`,
|
||||
);
|
||||
deps.io.verbose(`Has missing requirements: ${hasMissing}`);
|
||||
|
||||
// If there are missing requirements, print them out
|
||||
@@ -693,7 +697,7 @@ export const handleInvitationCommand = async (
|
||||
// Return the invitation identifier
|
||||
return { invitationIdentifier };
|
||||
}
|
||||
|
||||
|
||||
case "broadcast": {
|
||||
// Get the invitation identifier from the arguments
|
||||
const invitationIdentifier = args[1];
|
||||
@@ -940,7 +944,7 @@ export const handleInvitationCommand = async (
|
||||
deps.io.verbose(
|
||||
`Invitation created: ${formatObject(invitationInstance.data)}`,
|
||||
);
|
||||
|
||||
|
||||
// Return the invitation identifier
|
||||
return {
|
||||
invitationIdentifier: invitationInstance.data.invitationIdentifier,
|
||||
|
||||
@@ -37,7 +37,9 @@ function formatResource(
|
||||
showReserved = false,
|
||||
): string {
|
||||
// Format the template
|
||||
const template = resource.template ? dim(`[${generateTemplateIdentifier(resource.template)}]`) : "";
|
||||
const template = resource.template
|
||||
? dim(`[${generateTemplateIdentifier(resource.template)}]`)
|
||||
: "";
|
||||
|
||||
// Format the outpoint
|
||||
const outpoint = bold(
|
||||
@@ -51,7 +53,7 @@ function formatResource(
|
||||
const output = resource.outputIdentifier
|
||||
? dim(resource.outputIdentifier)
|
||||
: "";
|
||||
|
||||
|
||||
// Format the height
|
||||
const height = dim(`(height ${resource.minedAtHeight})`);
|
||||
|
||||
@@ -233,7 +235,7 @@ export const handleResourceCommand = async (
|
||||
deps.io.out(
|
||||
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
|
||||
);
|
||||
|
||||
|
||||
// TODO: What do I want to return here?
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const handleSettingsCommand = async (
|
||||
const value =
|
||||
key === "currency"
|
||||
? settings.getCurrency()
|
||||
: settings.getDefaultMnemonic() ?? "";
|
||||
: (settings.getDefaultMnemonic() ?? "");
|
||||
deps.io.out(value);
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
|
||||
import { bold, dim, formatObject } from "../utils.js";
|
||||
import { loadTemplateFromFile, TemplateLoadError } from "../../utils/load-template-from-file.js";
|
||||
import {
|
||||
loadTemplateFromFile,
|
||||
TemplateLoadError,
|
||||
} from "../../utils/load-template-from-file.js";
|
||||
import { resolveTemplateReferences } from "../../utils/templates.js";
|
||||
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||
import { CommandError } from "./types.js";
|
||||
|
||||
@@ -181,16 +181,20 @@ async function main(): Promise<void> {
|
||||
|
||||
// Create an App instance
|
||||
io.verbose("Creating app instance...");
|
||||
const app = await AppService.create(mnemonic, {
|
||||
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
|
||||
engineConfig: {
|
||||
databasePath: options["databasePath"] ?? paths.dataDir,
|
||||
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
|
||||
const app = await AppService.create(
|
||||
mnemonic,
|
||||
{
|
||||
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
|
||||
engineConfig: {
|
||||
databasePath: options["databasePath"] ?? paths.dataDir,
|
||||
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
|
||||
},
|
||||
invitationStoragePath:
|
||||
options["invitationStoragePath"] ??
|
||||
join(paths.dataDir, "xo-invitations.db"),
|
||||
},
|
||||
invitationStoragePath:
|
||||
options["invitationStoragePath"] ??
|
||||
join(paths.dataDir, "xo-invitations.db"),
|
||||
}, settings);
|
||||
settings,
|
||||
);
|
||||
io.verbose("App instance created");
|
||||
|
||||
// Start the app
|
||||
|
||||
@@ -100,8 +100,12 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
const templates = await engine.listImportedTemplates();
|
||||
|
||||
templates.forEach(async (template) => {
|
||||
engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template));
|
||||
engine.subscribeToScriptHashForTemplate(generateTemplateIdentifier(template));
|
||||
engine.updateUnspentOutputsForTemplate(
|
||||
generateTemplateIdentifier(template),
|
||||
);
|
||||
engine.subscribeToScriptHashForTemplate(
|
||||
generateTemplateIdentifier(template),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -127,7 +131,14 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
});
|
||||
const rates = await RatesService.create(settings);
|
||||
|
||||
return new AppService(engine, walletStorage, config, electrum, rates, settings);
|
||||
return new AppService(
|
||||
engine,
|
||||
walletStorage,
|
||||
config,
|
||||
electrum,
|
||||
rates,
|
||||
settings,
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -183,6 +194,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
|
||||
// Add the invitation to the invitations array
|
||||
this.invitations.push(invitation);
|
||||
this.bumpInvitationRevision(invitation.data.invitationIdentifier);
|
||||
|
||||
// Emit the invitation-added event
|
||||
this.emit("invitation-added", invitation);
|
||||
@@ -201,6 +213,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
if (invitationIndex >= 0) {
|
||||
this.invitations.splice(invitationIndex, 1);
|
||||
}
|
||||
this.bumpInvitationRevision(invitationIdentifier);
|
||||
|
||||
// Emit the invitation-removed event
|
||||
this.emit("invitation-removed", invitation);
|
||||
@@ -215,12 +228,14 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
if (this.invitationEventCleanup.has(invitationIdentifier)) return;
|
||||
|
||||
const onUpdated = () => {
|
||||
this.bumpInvitationRevision(invitationIdentifier);
|
||||
this.emit("wallet-state-changed", {
|
||||
reason: "invitation-updated",
|
||||
invitationIdentifier,
|
||||
});
|
||||
};
|
||||
const onStatusChanged = () => {
|
||||
this.bumpInvitationRevision(invitationIdentifier);
|
||||
this.emit("wallet-state-changed", {
|
||||
reason: "invitation-status-changed",
|
||||
invitationIdentifier,
|
||||
@@ -236,6 +251,18 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
});
|
||||
}
|
||||
|
||||
getInvitationRevision(invitationIdentifier: string): number {
|
||||
return this.invitationRevisions.get(invitationIdentifier) ?? 0;
|
||||
}
|
||||
|
||||
private bumpInvitationRevision(invitationIdentifier: string): void {
|
||||
this.invitationsRevision += 1;
|
||||
this.invitationRevisions.set(
|
||||
invitationIdentifier,
|
||||
this.getInvitationRevision(invitationIdentifier) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
private detachInvitationListeners(invitationIdentifier: string): void {
|
||||
const trackedInvitation = this.invitations.find(
|
||||
(candidate) =>
|
||||
@@ -282,9 +309,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Start rates in the background so BCH -> fiat conversions become reactive in the TUI.
|
||||
this.rates.start().catch((err) =>
|
||||
console.error('Error starting rates service:', err),
|
||||
);
|
||||
this.rates
|
||||
.start()
|
||||
.catch((err) => console.error("Error starting rates service:", err));
|
||||
|
||||
// Get the invitations db
|
||||
const invitationsDb = this.storage.child("invitations");
|
||||
|
||||
@@ -80,7 +80,7 @@ interface WalletMetadataIndex {
|
||||
* I've tried to fundamental approaches so far:
|
||||
* - UTXO first
|
||||
* - Invitation first
|
||||
*
|
||||
*
|
||||
* The issue is that neither of these end up being simple or effective
|
||||
* UTXO first makes tracking utxos across invitations extremely difficult. So if you receive a UTXO from an invitation and then spend it on another, you wont even see that old invitation.
|
||||
* Invitation first makes fitting UTXOs that dont have an invitation (say if someone sent directly to your address) extremely difficult. You end up having to run a UTXO first pass anyway, and then end up with conflicts around resolved roles.
|
||||
@@ -95,7 +95,6 @@ export class HistoryService {
|
||||
private invitations: Invitation[],
|
||||
) {}
|
||||
|
||||
|
||||
/**
|
||||
* I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes
|
||||
* But for the actual usage, UTXO is easier to follow - just not good for demo
|
||||
@@ -114,7 +113,10 @@ export class HistoryService {
|
||||
|
||||
for (const context of utxoContexts) {
|
||||
const invitationIdentifier = context.utxo.reservedBy;
|
||||
if (invitationIdentifier && invitationContexts.has(invitationIdentifier)) {
|
||||
if (
|
||||
invitationIdentifier &&
|
||||
invitationContexts.has(invitationIdentifier)
|
||||
) {
|
||||
const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? [];
|
||||
group.push(context);
|
||||
reservedUtxosByInvitation.set(invitationIdentifier, group);
|
||||
@@ -141,13 +143,15 @@ export class HistoryService {
|
||||
});
|
||||
}
|
||||
|
||||
private async buildInvitationContextIndex(): Promise<Map<string, InvitationContext>> {
|
||||
private async buildInvitationContextIndex(): Promise<
|
||||
Map<string, InvitationContext>
|
||||
> {
|
||||
const contexts = new Map<string, InvitationContext>();
|
||||
|
||||
for (const invitation of this.invitations) {
|
||||
const templateIdentifier = invitation.data.templateIdentifier;
|
||||
const template = templateIdentifier
|
||||
? (await this.engine.getTemplate(templateIdentifier)) ?? null
|
||||
? ((await this.engine.getTemplate(templateIdentifier)) ?? null)
|
||||
: null;
|
||||
contexts.set(invitation.data.invitationIdentifier, {
|
||||
invitation,
|
||||
@@ -181,9 +185,13 @@ export class HistoryService {
|
||||
}
|
||||
|
||||
for (const templateIdentifier of templateIdentifiers) {
|
||||
const scriptHashDataList = await this.engine.listScriptHashesForTemplate(templateIdentifier);
|
||||
const scriptHashDataList =
|
||||
await this.engine.listScriptHashesForTemplate(templateIdentifier);
|
||||
for (const scriptHashData of scriptHashDataList) {
|
||||
scriptHashDataByScriptHash.set(scriptHashData.scriptHash, scriptHashData);
|
||||
scriptHashDataByScriptHash.set(
|
||||
scriptHashData.scriptHash,
|
||||
scriptHashData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,10 +202,12 @@ export class HistoryService {
|
||||
utxo: UnspentOutputData,
|
||||
metadataIndex: WalletMetadataIndex,
|
||||
): Promise<UtxoContext> {
|
||||
const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash);
|
||||
const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(
|
||||
utxo.scriptHash,
|
||||
);
|
||||
const templateIdentifier = scriptHashData?.templateIdentifier;
|
||||
const template = templateIdentifier
|
||||
? (await this.engine.getTemplate(templateIdentifier)) ?? null
|
||||
? ((await this.engine.getTemplate(templateIdentifier)) ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
@@ -213,8 +223,15 @@ export class HistoryService {
|
||||
): WalletHistoryItem {
|
||||
const invitation = context.invitation.data;
|
||||
const entityRoles = this.deriveInvitationEntityRoles(context);
|
||||
const inputs = this.projectInvitationInputs(context, reservedContexts, entityRoles);
|
||||
const inputUtxoIds = this.listInvitationInputUtxoIds(context, reservedContexts);
|
||||
const inputs = this.projectInvitationInputs(
|
||||
context,
|
||||
reservedContexts,
|
||||
entityRoles,
|
||||
);
|
||||
const inputUtxoIds = this.listInvitationInputUtxoIds(
|
||||
context,
|
||||
reservedContexts,
|
||||
);
|
||||
const outputs = this.projectInvitationOutputs(
|
||||
context,
|
||||
reservedContexts,
|
||||
@@ -263,7 +280,9 @@ export class HistoryService {
|
||||
const outpointIndex = input.outpointIndex;
|
||||
if (txid === undefined || outpointIndex === undefined) continue;
|
||||
|
||||
const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex));
|
||||
const utxoContext = reservedByOutpoint.get(
|
||||
this.getOutpointKey(txid, outpointIndex),
|
||||
);
|
||||
// TODO: Remove this reservation-based filter once Engine/library cleanup releases stale invitation reservations internally.
|
||||
if (!utxoContext) continue;
|
||||
|
||||
@@ -309,15 +328,20 @@ export class HistoryService {
|
||||
// UTXO-first: committed outputs only matter here if they resolve to a wallet UTXO currently reserved by this invitation.
|
||||
if (!matchingContext) continue;
|
||||
|
||||
const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode;
|
||||
const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier;
|
||||
const lockingBytecode =
|
||||
this.getOutputLockingBytecodeHex(output) ??
|
||||
matchingContext.scriptHashData?.lockingBytecode;
|
||||
const outputIdentifier =
|
||||
output.outputIdentifier ??
|
||||
matchingContext.scriptHashData?.outputIdentifier;
|
||||
const role =
|
||||
output.roleIdentifier ??
|
||||
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
|
||||
matchingContext.scriptHashData?.roleIdentifier;
|
||||
const valueSatoshis = output.valueSatoshis !== undefined
|
||||
? BigInt(output.valueSatoshis)
|
||||
: BigInt(matchingContext.utxo.valueSatoshis);
|
||||
const valueSatoshis =
|
||||
output.valueSatoshis !== undefined
|
||||
? BigInt(output.valueSatoshis)
|
||||
: BigInt(matchingContext.utxo.valueSatoshis);
|
||||
|
||||
usedUtxoIds.add(this.getUtxoId(matchingContext.utxo));
|
||||
|
||||
@@ -369,8 +393,11 @@ export class HistoryService {
|
||||
const outpointIndex = input.outpointIndex;
|
||||
if (txid === undefined || outpointIndex === undefined) continue;
|
||||
|
||||
const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex));
|
||||
if (utxoContext) invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo));
|
||||
const utxoContext = reservedByOutpoint.get(
|
||||
this.getOutpointKey(txid, outpointIndex),
|
||||
);
|
||||
if (utxoContext)
|
||||
invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,9 +417,17 @@ export class HistoryService {
|
||||
return reservedContexts.find((context) => {
|
||||
if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false;
|
||||
if (scriptHash && context.utxo.scriptHash === scriptHash) return true;
|
||||
if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true;
|
||||
if (
|
||||
lockingBytecode &&
|
||||
context.scriptHashData?.lockingBytecode === lockingBytecode
|
||||
)
|
||||
return true;
|
||||
|
||||
if (output.outputIdentifier && context.scriptHashData?.outputIdentifier === output.outputIdentifier) return true;
|
||||
if (
|
||||
output.outputIdentifier &&
|
||||
context.scriptHashData?.outputIdentifier === output.outputIdentifier
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -423,7 +458,11 @@ export class HistoryService {
|
||||
id: this.getUtxoId(context.utxo),
|
||||
outputIdentifier,
|
||||
role,
|
||||
description: this.describeOutputFromTemplate(outputIdentifier, context.template, {}),
|
||||
description: this.describeOutputFromTemplate(
|
||||
outputIdentifier,
|
||||
context.template,
|
||||
{},
|
||||
),
|
||||
valueSatoshis: BigInt(context.utxo.valueSatoshis),
|
||||
outpoint: {
|
||||
txid: context.utxo.outpointTransactionHash,
|
||||
@@ -435,17 +474,22 @@ export class HistoryService {
|
||||
};
|
||||
}
|
||||
|
||||
private deriveInvitationEntityRoles(context: InvitationContext): Map<string, string[]> {
|
||||
private deriveInvitationEntityRoles(
|
||||
context: InvitationContext,
|
||||
): Map<string, string[]> {
|
||||
const invitation = context.invitation.data;
|
||||
const rolesByEntity = new Map<string, Set<string>>();
|
||||
const allEntities = new Set(invitation.commits.map((commit) => commit.entityIdentifier));
|
||||
const allEntities = new Set(
|
||||
invitation.commits.map((commit) => commit.entityIdentifier),
|
||||
);
|
||||
|
||||
for (const entityIdentifier of allEntities) {
|
||||
rolesByEntity.set(entityIdentifier, new Set());
|
||||
}
|
||||
|
||||
for (const commit of invitation.commits) {
|
||||
const roles = rolesByEntity.get(commit.entityIdentifier) ?? new Set<string>();
|
||||
const roles =
|
||||
rolesByEntity.get(commit.entityIdentifier) ?? new Set<string>();
|
||||
for (const input of commit.data.inputs ?? []) {
|
||||
if (input.roleIdentifier) roles.add(input.roleIdentifier);
|
||||
}
|
||||
@@ -459,9 +503,10 @@ export class HistoryService {
|
||||
}
|
||||
|
||||
const action = context.template?.actions?.[invitation.actionIdentifier];
|
||||
const participantRoles = action?.requirements?.participants
|
||||
?.map((participant) => participant.role)
|
||||
.filter((role): role is string => typeof role === "string") ?? [];
|
||||
const participantRoles =
|
||||
action?.requirements?.participants
|
||||
?.map((participant) => participant.role)
|
||||
.filter((role): role is string => typeof role === "string") ?? [];
|
||||
const explicitlyFilledRoles = new Set<string>();
|
||||
for (const roles of rolesByEntity.values()) {
|
||||
for (const role of roles) explicitlyFilledRoles.add(role);
|
||||
@@ -473,7 +518,10 @@ export class HistoryService {
|
||||
.filter(([, roles]) => roles.size === 0)
|
||||
.map(([entityIdentifier]) => entityIdentifier);
|
||||
|
||||
if (unfilledParticipantRoles.length === 1 && entitiesWithoutRoles.length >= 1) {
|
||||
if (
|
||||
unfilledParticipantRoles.length === 1 &&
|
||||
entitiesWithoutRoles.length >= 1
|
||||
) {
|
||||
const inferredRole = unfilledParticipantRoles[0];
|
||||
if (inferredRole !== undefined) {
|
||||
for (const entityIdentifier of entitiesWithoutRoles) {
|
||||
@@ -517,12 +565,21 @@ export class HistoryService {
|
||||
inputs: WalletHistoryInput[],
|
||||
outputs: WalletHistoryOutput[],
|
||||
): bigint {
|
||||
const inputTotal = inputs.reduce((total, input) => total + (input.valueSatoshis ?? 0n), 0n);
|
||||
const outputTotal = outputs.reduce((total, output) => total + (output.valueSatoshis ?? 0n), 0n);
|
||||
const inputTotal = inputs.reduce(
|
||||
(total, input) => total + (input.valueSatoshis ?? 0n),
|
||||
0n,
|
||||
);
|
||||
const outputTotal = outputs.reduce(
|
||||
(total, output) => total + (output.valueSatoshis ?? 0n),
|
||||
0n,
|
||||
);
|
||||
return inputTotal + outputTotal;
|
||||
}
|
||||
|
||||
private describeInvitation(context: InvitationContext, role?: string): string {
|
||||
private describeInvitation(
|
||||
context: InvitationContext,
|
||||
role?: string,
|
||||
): string {
|
||||
const invitation = context.invitation.data;
|
||||
const template = context.template;
|
||||
if (!template) return invitation.actionIdentifier;
|
||||
@@ -544,14 +601,27 @@ export class HistoryService {
|
||||
return this.compileDescription(descriptionTemplate, context.variables);
|
||||
}
|
||||
|
||||
private describeInput(inputIdentifier: string | undefined, context: InvitationContext): string {
|
||||
private describeInput(
|
||||
inputIdentifier: string | undefined,
|
||||
context: InvitationContext,
|
||||
): string {
|
||||
if (!inputIdentifier) return "Input";
|
||||
const input = context.template?.inputs?.[inputIdentifier];
|
||||
return this.compileDescription(input?.description ?? input?.name ?? inputIdentifier, context.variables);
|
||||
return this.compileDescription(
|
||||
input?.description ?? input?.name ?? inputIdentifier,
|
||||
context.variables,
|
||||
);
|
||||
}
|
||||
|
||||
private describeOutput(outputIdentifier: string | undefined, context: InvitationContext): string {
|
||||
return this.describeOutputFromTemplate(outputIdentifier, context.template, context.variables);
|
||||
private describeOutput(
|
||||
outputIdentifier: string | undefined,
|
||||
context: InvitationContext,
|
||||
): string {
|
||||
return this.describeOutputFromTemplate(
|
||||
outputIdentifier,
|
||||
context.template,
|
||||
context.variables,
|
||||
);
|
||||
}
|
||||
|
||||
private describeOutputFromTemplate(
|
||||
@@ -561,7 +631,10 @@ export class HistoryService {
|
||||
): string {
|
||||
if (!outputIdentifier) return "Output";
|
||||
const output = template?.outputs?.[outputIdentifier];
|
||||
return this.compileDescription(output?.description ?? output?.name ?? outputIdentifier, variables);
|
||||
return this.compileDescription(
|
||||
output?.description ?? output?.name ?? outputIdentifier,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
|
||||
private compileDescription(
|
||||
@@ -569,16 +642,25 @@ export class HistoryService {
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
): string {
|
||||
try {
|
||||
return compileCashAssemblyString({ cashAssemblyText: description, variables, evaluationDecodeMode: 'utf8' });
|
||||
return compileCashAssemblyString({
|
||||
cashAssemblyText: description,
|
||||
variables,
|
||||
evaluationDecodeMode: "utf8",
|
||||
});
|
||||
} catch {
|
||||
return this.interpolateSimpleCashAssemblyVariables(description, variables);
|
||||
return this.interpolateSimpleCashAssemblyVariables(
|
||||
description,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private extractInvitationVariables(
|
||||
invitation: XOInvitation,
|
||||
): Record<string, XOInvitationVariableValue> {
|
||||
const committedVariables = invitation.commits.flatMap((c) => c.data.variables ?? []);
|
||||
const committedVariables = invitation.commits.flatMap(
|
||||
(c) => c.data.variables ?? [],
|
||||
);
|
||||
return committedVariables.reduce(
|
||||
(acc, variable) => {
|
||||
if (!variable.variableIdentifier) return acc;
|
||||
@@ -596,15 +678,21 @@ export class HistoryService {
|
||||
: String(input.outpointTransactionHash);
|
||||
}
|
||||
|
||||
private getOutputLockingBytecodeHex(output: XOInvitationOutput): string | undefined {
|
||||
private getOutputLockingBytecodeHex(
|
||||
output: XOInvitationOutput,
|
||||
): string | undefined {
|
||||
if (output.lockingBytecode === undefined) return undefined;
|
||||
return typeof output.lockingBytecode === "string"
|
||||
? output.lockingBytecode
|
||||
: binToHex(output.lockingBytecode);
|
||||
}
|
||||
|
||||
private async getScriptHashData(scriptHash: string): Promise<ScriptHashData | undefined> {
|
||||
return (this.engine as unknown as { state: State }).state.getScriptHashData(scriptHash);
|
||||
private async getScriptHashData(
|
||||
scriptHash: string,
|
||||
): Promise<ScriptHashData | undefined> {
|
||||
return (this.engine as unknown as { state: State }).state.getScriptHashData(
|
||||
scriptHash,
|
||||
);
|
||||
}
|
||||
|
||||
private getOutpointKey(txid: string, index: number): string {
|
||||
@@ -627,7 +715,9 @@ export class HistoryService {
|
||||
return text.replace(
|
||||
/\$\(<([^>]+)>\)/g,
|
||||
(match, variableIdentifier: string) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)
|
||||
) {
|
||||
return match;
|
||||
}
|
||||
return String(variables[variableIdentifier]);
|
||||
|
||||
@@ -3,7 +3,13 @@ import type {
|
||||
Engine,
|
||||
GetSpendableResourcesParameters,
|
||||
} from "@xo-cash/engine";
|
||||
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation } from "@xo-cash/engine";
|
||||
import {
|
||||
generateTemplateIdentifier,
|
||||
hasInvitationExpired,
|
||||
mergeInvitationCommits,
|
||||
serializeInvitation,
|
||||
deserializeInvitation,
|
||||
} from "@xo-cash/engine";
|
||||
import type {
|
||||
XOInvitation,
|
||||
XOInvitationCommit,
|
||||
@@ -43,6 +49,13 @@ export type InvitationDependencies = {
|
||||
electrum: BlockchainService;
|
||||
};
|
||||
|
||||
function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation {
|
||||
const { entityIdentifier: _entityIdentifier, ...sharedInvitation } =
|
||||
invitation as XOInvitation & { entityIdentifier?: string };
|
||||
|
||||
return sharedInvitation;
|
||||
}
|
||||
|
||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* Create an invitation and start the SSE Session required for it.
|
||||
@@ -85,14 +98,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
}
|
||||
|
||||
// engine invitation (I have no idea if this is required)
|
||||
const engineInvitation = await dependencies.engine.importInvitation(serializeInvitation(invitation));
|
||||
const engineInvitation = await dependencies.engine.importInvitation(
|
||||
serializeInvitation(invitation),
|
||||
);
|
||||
|
||||
// Create the invitation
|
||||
const invitationInstance = new Invitation(engineInvitation, dependencies);
|
||||
|
||||
// Start the invitation and its tracking
|
||||
invitationInstance.start();
|
||||
|
||||
return invitationInstance;
|
||||
}
|
||||
|
||||
@@ -123,6 +135,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
*/
|
||||
private storage: BaseStorage;
|
||||
private electrum: BlockchainService;
|
||||
private sseUpdateQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||
@@ -141,8 +154,23 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.storage = dependencies.storage;
|
||||
this.electrum = dependencies.electrum;
|
||||
|
||||
// Create a listerner for the messages from the SSE Session (sync server)
|
||||
this.syncServer.on("message", this.handleSSEMessage.bind(this));
|
||||
// Apply SSE updates serially so each engine update sees the latest history.
|
||||
this.syncServer.on("message", (event) => {
|
||||
this.enqueueSyncUpdate(() => this.handleSSEMessage(event)).catch(
|
||||
(error) => {
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueSyncUpdate(update: () => Promise<void>): Promise<void> {
|
||||
const queuedUpdate = this.sseUpdateQueue.then(update);
|
||||
this.sseUpdateQueue = queuedUpdate.catch(() => {});
|
||||
return queuedUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,20 +188,32 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
||||
]);
|
||||
|
||||
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
|
||||
const sseCommits = this.data.commits;
|
||||
await this.enqueueSyncUpdate(async () => {
|
||||
// SSE messages can arrive before the GET request completes.
|
||||
const combinedCommits = this.mergeCommits(
|
||||
this.data.commits,
|
||||
invitation?.commits ?? [],
|
||||
);
|
||||
|
||||
// Merge the commits
|
||||
const combinedCommits = this.mergeCommits(
|
||||
sseCommits,
|
||||
invitation?.commits ?? [],
|
||||
);
|
||||
try {
|
||||
// Prefer keeping the engine's local invitation state in sync.
|
||||
this.data = stripLocalInvitationMetadata(
|
||||
await this.engine.updateInvitation({
|
||||
...this.data,
|
||||
...invitation,
|
||||
commits: combinedCommits,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
this.data = { ...this.data, commits: combinedCommits };
|
||||
}
|
||||
|
||||
// Set the invitation data with the combined commits
|
||||
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
||||
|
||||
// Store the invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
});
|
||||
|
||||
// Publish the invitation to the sync server
|
||||
this.publishInvitation(this.data);
|
||||
@@ -181,8 +221,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// Compute and emit initial status
|
||||
await this.updateStatus();
|
||||
} catch (err) {
|
||||
// console.error(`Error starting invitation, could not connect to sync server or get invitation`, err);
|
||||
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
|
||||
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
@@ -192,30 +230,85 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
private handleSSEMessage(event: SSEvent): void {
|
||||
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||
if (data.topic === "invitation-updated") {
|
||||
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
||||
|
||||
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
// Set the new commits
|
||||
this.data = { ...this.data, commits: newCommits };
|
||||
|
||||
// Calculate the new status of the invitation (fire-and-forget; handler is sync)
|
||||
this.updateStatus().catch(() => {});
|
||||
|
||||
// Emit the updated event
|
||||
this.emit("invitation-updated", this.data);
|
||||
private async handleSSEMessage(event: SSEvent): Promise<void> {
|
||||
const invitation = this.parseInvitationFromSSEMessage(event);
|
||||
if (
|
||||
!invitation ||
|
||||
invitation.invitationIdentifier !== this.data.invitationIdentifier
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out commits that already exist
|
||||
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
||||
|
||||
try {
|
||||
this.data = stripLocalInvitationMetadata(
|
||||
await this.engine.updateInvitation({
|
||||
...this.data,
|
||||
...invitation,
|
||||
commits: newCommits,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
this.data = { ...this.data, commits: newCommits };
|
||||
}
|
||||
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
await this.updateStatus();
|
||||
this.emit("invitation-updated", this.data);
|
||||
}
|
||||
|
||||
private parseInvitationFromSSEMessage(event: SSEvent): XOInvitation | null {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data) as unknown;
|
||||
const payload =
|
||||
event.event === "invitation-updated"
|
||||
? this.unwrapInvitationUpdatedPayload(parsed)
|
||||
: this.unwrapLegacyInvitationUpdatedPayload(parsed);
|
||||
|
||||
if (!payload) return null;
|
||||
|
||||
const decoded = decodeExtendedJsonObject(payload) as XOInvitation;
|
||||
return stripLocalInvitationMetadata(
|
||||
deserializeInvitation(serializeInvitation(decoded)),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private unwrapInvitationUpdatedPayload(payload: unknown): unknown | null {
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"topic" in payload &&
|
||||
"data" in payload
|
||||
) {
|
||||
return this.unwrapLegacyInvitationUpdatedPayload(payload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private unwrapLegacyInvitationUpdatedPayload(
|
||||
payload: unknown,
|
||||
): unknown | null {
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"topic" in payload &&
|
||||
"data" in payload &&
|
||||
payload.topic === "invitation-updated"
|
||||
) {
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,7 +318,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
invitation: XOInvitation = this.data,
|
||||
): Promise<void> {
|
||||
this.syncServer.publishInvitation(invitation).catch((error) => {
|
||||
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -279,7 +375,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
private async computeStatusInternal(): Promise<string> {
|
||||
let missingReqs;
|
||||
try {
|
||||
const missingRequirements = await this.engine.listMissingRequirements(this.data.invitationIdentifier);
|
||||
const missingRequirements = await this.engine.listMissingRequirements(
|
||||
this.data.invitationIdentifier,
|
||||
);
|
||||
missingReqs = missingRequirements.templateRequirements;
|
||||
} catch {
|
||||
return "unknown";
|
||||
@@ -371,13 +469,18 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* Update the status of the invitation and emit the new single-word status.
|
||||
*/
|
||||
private async updateStatus(): Promise<void> {
|
||||
this.computeStatus().then(status => {
|
||||
this.status = status;
|
||||
this.emit("invitation-status-changed", status);
|
||||
}).catch((error) => {
|
||||
this.status = `error (${error instanceof Error ? error.message : String(error)})`;
|
||||
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
this.computeStatus()
|
||||
.then((status) => {
|
||||
this.status = status;
|
||||
this.emit("invitation-status-changed", status);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.status = `error (${error instanceof Error ? error.message : String(error)})`;
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -388,18 +491,37 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
this.publishInvitation(this.data);
|
||||
await this.publishInvitation(this.data);
|
||||
|
||||
// Store the accepted invitation and notify reactive consumers.
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
this.emit("invitation-updated", this.data);
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the invitation once for this engine entity so future appends have a root commit.
|
||||
*/
|
||||
async ensureAccepted(): Promise<void> {
|
||||
const ownCommits = await this.engine.findOwnCommits(
|
||||
this.data.invitationIdentifier,
|
||||
);
|
||||
|
||||
if (ownCommits.length === 0) {
|
||||
await this.accept();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the invitation
|
||||
*/
|
||||
async sign(): Promise<void> {
|
||||
// Sign the invitation
|
||||
const signedInvitation = await this.engine.signInvitation(this.data.invitationIdentifier);
|
||||
const signedInvitation = await this.engine.signInvitation(
|
||||
this.data.invitationIdentifier,
|
||||
);
|
||||
|
||||
// Publish the signed invitation to the sync server
|
||||
this.publishInvitation(signedInvitation);
|
||||
@@ -418,9 +540,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* @returns The transaction hash returned by the network after broadcast.
|
||||
*/
|
||||
async broadcast(): Promise<string> {
|
||||
const txHash = await this.engine.executeAction(this.data.invitationIdentifier, {
|
||||
broadcastTransaction: true,
|
||||
});
|
||||
const txHash = await this.engine.executeAction(
|
||||
this.data.invitationIdentifier,
|
||||
{
|
||||
broadcastTransaction: true,
|
||||
},
|
||||
);
|
||||
|
||||
await this.updateStatus();
|
||||
|
||||
@@ -435,14 +560,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* Append a commit to the invitation
|
||||
*/
|
||||
async append(data: InvitationParameters): Promise<void> {
|
||||
try {
|
||||
await this.engine.acceptInvitation(this.data);
|
||||
} catch (err) {
|
||||
// Literally do nothing here. We are just trying to accept the invitation in case we haven't already
|
||||
}
|
||||
await this.ensureAccepted();
|
||||
|
||||
// Append the commit to the invitation
|
||||
this.data = await this.engine.appendInvitation(this.data.invitationIdentifier, data);
|
||||
this.data = await this.engine.appendInvitation(
|
||||
this.data.invitationIdentifier,
|
||||
data,
|
||||
);
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
await this.publishInvitation(this.data);
|
||||
@@ -521,8 +645,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
const templates = await this.engine.listImportedTemplates();
|
||||
|
||||
// For each template, we need to create a 2d array of all the outputs
|
||||
const outputs = templates.map(template => {
|
||||
return Object.keys(template.outputs).map(output => {
|
||||
const outputs = templates.map((template) => {
|
||||
return Object.keys(template.outputs).map((output) => {
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
|
||||
return {
|
||||
@@ -533,14 +657,18 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
});
|
||||
|
||||
// then, for each output, we need to get the spendable resources
|
||||
const spendableResources = await Promise.all(outputs.flat().map(output => {
|
||||
return this.engine.getSpendableResources(this.data, {
|
||||
templateIdentifier: output.templateIdentifier,
|
||||
outputIdentifier: output.outputIdentifier,
|
||||
});
|
||||
}));
|
||||
const spendableResources = await Promise.all(
|
||||
outputs.flat().map((output) => {
|
||||
return this.engine.getSpendableResources(this.data, {
|
||||
templateIdentifier: output.templateIdentifier,
|
||||
outputIdentifier: output.outputIdentifier,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const unspentOutputs = spendableResources.flatMap(resource => resource.unspentOutputs);
|
||||
const unspentOutputs = spendableResources.flatMap(
|
||||
(resource) => resource.unspentOutputs,
|
||||
);
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
@@ -642,9 +770,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
);
|
||||
|
||||
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
|
||||
const valueSatoshis = compileCashAssemblyString(
|
||||
{ cashAssemblyText: String(valueSatoshisExpression), variables: formattedVariables, evaluationDecodeMode: 'bigint' },
|
||||
);
|
||||
const valueSatoshis = compileCashAssemblyString({
|
||||
cashAssemblyText: String(valueSatoshisExpression),
|
||||
variables: formattedVariables,
|
||||
evaluationDecodeMode: "bigint",
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -700,7 +830,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
for (const output of outputs) {
|
||||
if (typeof output === "string") {
|
||||
const sats = await this.getSatsOut(output);
|
||||
totalSats += sats
|
||||
totalSats += sats;
|
||||
} else {
|
||||
const sats = await this.getSatsOut(output.output);
|
||||
totalSats += sats;
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { OracleClient } from '@generalprotocols/oracle-client';
|
||||
import { EventEmitter } from '../utils/event-emitter.js';
|
||||
import {
|
||||
type RatesEventMap,
|
||||
} from '../utils/rates/base-rates.js';
|
||||
import { RatesOracle } from '../utils/rates/rates-oracles.js';
|
||||
import { SettingsService } from './settings.js';
|
||||
import { OracleClient } from "@generalprotocols/oracle-client";
|
||||
import { EventEmitter } from "../utils/event-emitter.js";
|
||||
import { type RatesEventMap } from "../utils/rates/base-rates.js";
|
||||
import { RatesOracle } from "../utils/rates/rates-oracles.js";
|
||||
import { SettingsService } from "./settings.js";
|
||||
|
||||
/**
|
||||
* Event map emitted by {@link RatesService}.
|
||||
*/
|
||||
export type RatesServiceEventMap = {
|
||||
'rate-updated': {
|
||||
"rate-updated": {
|
||||
numeratorUnitCode: string;
|
||||
denominatorUnitCode: string;
|
||||
price: number;
|
||||
@@ -39,8 +37,8 @@ export interface RatesAdapter {
|
||||
listPairs(): Promise<Set<string>>;
|
||||
formatCurrency(amount: number, targetCurrency: string): string;
|
||||
on(
|
||||
type: 'rateUpdated',
|
||||
listener: (detail: RatesEventMap['rateUpdated']) => void,
|
||||
type: "rateUpdated",
|
||||
listener: (detail: RatesEventMap["rateUpdated"]) => void,
|
||||
): () => void;
|
||||
}
|
||||
|
||||
@@ -96,7 +94,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||
}
|
||||
this.started = true;
|
||||
|
||||
this.unsubscribeFromAdapter = this.adapter.on('rateUpdated', (event) => {
|
||||
this.unsubscribeFromAdapter = this.adapter.on("rateUpdated", (event) => {
|
||||
this.handleRateUpdated(event);
|
||||
});
|
||||
|
||||
@@ -145,9 +143,9 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||
*/
|
||||
public convertBchToFiat(
|
||||
satoshis: bigint,
|
||||
targetCurrency: string = 'USD',
|
||||
targetCurrency: string = "USD",
|
||||
): number | null {
|
||||
const rate = this.getRate(targetCurrency, 'BCH');
|
||||
const rate = this.getRate(targetCurrency, "BCH");
|
||||
if (rate === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -161,7 +159,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||
*/
|
||||
public formatBchToFiat(
|
||||
satoshis: bigint,
|
||||
targetCurrency: string = 'USD',
|
||||
targetCurrency: string = "USD",
|
||||
): string | null {
|
||||
const normalizedCurrency = targetCurrency.toUpperCase();
|
||||
const amount = this.convertBchToFiat(satoshis, normalizedCurrency);
|
||||
@@ -195,7 +193,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||
/**
|
||||
* Handles normalized updates from the underlying adapter.
|
||||
*/
|
||||
private handleRateUpdated(event: RatesEventMap['rateUpdated']): void {
|
||||
private handleRateUpdated(event: RatesEventMap["rateUpdated"]): void {
|
||||
const numeratorUnitCode = event.numeratorUnitCode.toUpperCase();
|
||||
const denominatorUnitCode = event.denominatorUnitCode.toUpperCase();
|
||||
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
|
||||
@@ -206,7 +204,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
this.emit('rate-updated', {
|
||||
this.emit("rate-updated", {
|
||||
numeratorUnitCode,
|
||||
denominatorUnitCode,
|
||||
price: event.price,
|
||||
|
||||
@@ -32,7 +32,7 @@ const DEFAULT_SETTINGS: SettingsData = {
|
||||
/**
|
||||
* Handles loading, migrating, and persisting wallet settings.
|
||||
*
|
||||
* The backing file is `~/.config/xo-cli/.wallet`. Historically it stored a raw
|
||||
* The backing file is `<XO_CONFIG_DIR>/.wallet`. Historically it stored a raw
|
||||
* mnemonic reference string. This service migrates that legacy format to JSON:
|
||||
* `{ "default-mnemonic": "<value>", "currency": "USD" }`.
|
||||
*/
|
||||
@@ -168,7 +168,9 @@ export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const maybeMnemonic = (input as Record<string, unknown>)["default-mnemonic"];
|
||||
const maybeMnemonic = (input as Record<string, unknown>)[
|
||||
"default-mnemonic"
|
||||
];
|
||||
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
|
||||
normalized["default-mnemonic"] = maybeMnemonic.trim();
|
||||
}
|
||||
@@ -191,4 +193,4 @@ export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
|
||||
}
|
||||
return normalizedCurrency;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export class Storage extends BaseStorage {
|
||||
*
|
||||
* This adapter is useful for tests and short-lived sessions where persisted
|
||||
* SQLite state is not needed.
|
||||
*
|
||||
*
|
||||
* TODO: Move this somewhere else. There is no reason for this to be in the main codebase. We should put this stricly in the tests beacuse that were its actually being used.
|
||||
* Ideally, we would provide these kind of generic fills as part of our packages somewhere, but these interfaces dont fit our current design.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
|
||||
/**
|
||||
* Vending machine payment template.
|
||||
@@ -7,271 +7,277 @@ import type { XOTemplate } from '@xo-cash/types';
|
||||
* customer funds and signs the composable transaction.
|
||||
*/
|
||||
export const vendingMachineTemplate: XOTemplate = {
|
||||
$schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json',
|
||||
name: 'Vending Machine',
|
||||
description: 'Purchase items from a vending machine with an itemized receipt.',
|
||||
icon: 'wallet',
|
||||
version: '1',
|
||||
supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'],
|
||||
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
|
||||
name: "Vending Machine",
|
||||
description:
|
||||
"Purchase items from a vending machine with an itemized receipt.",
|
||||
icon: "wallet",
|
||||
version: "1",
|
||||
supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
|
||||
|
||||
defaults: {
|
||||
change: {
|
||||
output: 'changeOutput',
|
||||
role: 'merchant',
|
||||
generate: ['merchantKey'],
|
||||
},
|
||||
defaults: {
|
||||
change: {
|
||||
output: "changeOutput",
|
||||
role: "merchant",
|
||||
generate: ["merchantKey"],
|
||||
},
|
||||
},
|
||||
|
||||
roles: {
|
||||
roles: {
|
||||
merchant: {
|
||||
name: "Merchant",
|
||||
description: "The vending machine operator receiving payment.",
|
||||
icon: "owner",
|
||||
},
|
||||
customer: {
|
||||
name: "Customer",
|
||||
description: "The customer paying for items.",
|
||||
icon: "sender",
|
||||
},
|
||||
},
|
||||
|
||||
start: [
|
||||
{
|
||||
action: "purchaseItems",
|
||||
role: "merchant",
|
||||
generate: ["merchantKey"],
|
||||
},
|
||||
],
|
||||
|
||||
actions: {
|
||||
purchaseItems: {
|
||||
name: "Purchase Items",
|
||||
description: "Purchase: $(<receiptSummary>) for $(<totalSatoshis>) sats",
|
||||
icon: "request",
|
||||
|
||||
roles: {
|
||||
merchant: {
|
||||
name: 'Merchant',
|
||||
description: 'The vending machine operator receiving payment.',
|
||||
icon: 'owner',
|
||||
name: "Sell Items",
|
||||
description: "Receive payment for $(<receiptSummary>)",
|
||||
icon: "request",
|
||||
requirements: {
|
||||
secrets: ["merchantKey"],
|
||||
variables: [
|
||||
"totalSatoshis",
|
||||
"orderId",
|
||||
"merchantName",
|
||||
"receiptSummary",
|
||||
"lineItemsJson",
|
||||
],
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
name: 'Customer',
|
||||
description: 'The customer paying for items.',
|
||||
icon: 'sender',
|
||||
name: "Pay",
|
||||
description: "Pay $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||
icon: "send",
|
||||
requirements: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
start: [
|
||||
requirements: {
|
||||
participants: [
|
||||
{ role: "merchant", slots: { min: 1, max: 1 } },
|
||||
{ role: "customer", slots: { min: 1 } },
|
||||
],
|
||||
},
|
||||
|
||||
transaction: "purchaseItemsTransaction",
|
||||
},
|
||||
},
|
||||
|
||||
transactions: {
|
||||
purchaseItemsTransaction: {
|
||||
name: "Vending Purchase",
|
||||
description: "Order $(<orderId>): $(<receiptSummary>)",
|
||||
icon: "request",
|
||||
|
||||
roles: {
|
||||
merchant: {
|
||||
name: "Received Payment",
|
||||
description:
|
||||
"Received $(<totalSatoshis>) sats from $(<merchantName>) sale",
|
||||
icon: "receive",
|
||||
},
|
||||
customer: {
|
||||
name: "Sent Payment",
|
||||
description: "Paid $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||
icon: "send",
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
outputs: [{ output: "purchaseOutput" }],
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
composable: true,
|
||||
},
|
||||
},
|
||||
|
||||
/** No custom input templates — customer UTXOs are selected at funding time. */
|
||||
inputs: {},
|
||||
|
||||
outputs: {
|
||||
changeOutput: {
|
||||
name: "Change",
|
||||
description: "Funds returned as change.",
|
||||
icon: "receive",
|
||||
lockingScript: "merchantReceivingLockingScript",
|
||||
},
|
||||
purchaseOutput: {
|
||||
name: "Purchase Payment",
|
||||
description: "$(<totalSatoshis>) sats to $(<merchantName>)",
|
||||
icon: "request",
|
||||
|
||||
roles: {
|
||||
merchant: {
|
||||
name: "Payment Received",
|
||||
description:
|
||||
"Received $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||
},
|
||||
customer: {
|
||||
name: "Payment Sent",
|
||||
description: "Sent $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||
},
|
||||
},
|
||||
|
||||
lockingScript: "merchantReceivingLockingScript",
|
||||
valueSatoshis: "$(<totalSatoshis>)",
|
||||
token: null,
|
||||
},
|
||||
},
|
||||
|
||||
lockingScripts: {
|
||||
merchantReceivingLockingScript: {
|
||||
name: "Merchant Receive",
|
||||
description: "Funds received by the vending machine merchant.",
|
||||
icon: "address",
|
||||
lockingType: "p2pkh",
|
||||
lockingBytecode: "lockMerchantP2PKH",
|
||||
unlockingBytecode: "unlockMerchantP2PKH",
|
||||
actions: [],
|
||||
state: { variables: [], secrets: [] },
|
||||
balance: {},
|
||||
roles: {
|
||||
merchant: {
|
||||
state: {
|
||||
variables: [],
|
||||
secrets: ["merchantKey"],
|
||||
},
|
||||
actions: [],
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
scripts: {
|
||||
lockMerchantP2PKH:
|
||||
"OP_DUP OP_HASH160 <$(<merchantKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG",
|
||||
unlockMerchantP2PKH:
|
||||
"<merchantKey.schnorr_signature.all_outputs> <merchantKey.public_key>",
|
||||
},
|
||||
|
||||
constants: {
|
||||
dustLimit: {
|
||||
name: "Dust Limit",
|
||||
description: "Minimum satoshis for P2PKH outputs.",
|
||||
type: "integer",
|
||||
value: 546,
|
||||
},
|
||||
},
|
||||
|
||||
variables: {
|
||||
merchantKey: {
|
||||
name: "Merchant Private Key",
|
||||
description: "Private key for the vending machine merchant wallet.",
|
||||
type: "bytes",
|
||||
hint: "private_key",
|
||||
},
|
||||
totalSatoshis: {
|
||||
name: "Total Price",
|
||||
description: "Total purchase price in satoshis",
|
||||
type: "integer",
|
||||
hint: "satoshis",
|
||||
},
|
||||
orderId: {
|
||||
name: "Order ID",
|
||||
description: "Unique order identifier",
|
||||
type: "string",
|
||||
},
|
||||
merchantName: {
|
||||
name: "Merchant Name",
|
||||
description: "Display name of the vending machine",
|
||||
type: "string",
|
||||
},
|
||||
receiptSummary: {
|
||||
name: "Receipt Summary",
|
||||
description: "Human-readable list of purchased items",
|
||||
type: "string",
|
||||
},
|
||||
lineItemsJson: {
|
||||
name: "Line Items",
|
||||
description: "JSON-encoded line items for the purchase",
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
|
||||
icons: [
|
||||
{ name: "wallet", hash: "0000000000000000000000" },
|
||||
{ name: "owner", hash: "0000000000000000000000" },
|
||||
{ name: "sender", hash: "0000000000000000000000" },
|
||||
{ name: "request", hash: "0000000000000000000000" },
|
||||
{ name: "receive", hash: "0000000000000000000000" },
|
||||
{ name: "send", hash: "0000000000000000000000" },
|
||||
],
|
||||
|
||||
scenarios: [
|
||||
{
|
||||
name: "purchase items happy path",
|
||||
description: "Merchant requests payment for vending machine items.",
|
||||
action: "purchaseItems",
|
||||
roles: [
|
||||
{
|
||||
action: 'purchaseItems',
|
||||
role: 'merchant',
|
||||
generate: ['merchantKey'],
|
||||
},
|
||||
],
|
||||
|
||||
actions: {
|
||||
purchaseItems: {
|
||||
name: 'Purchase Items',
|
||||
description: 'Purchase: $(<receiptSummary>) for $(<totalSatoshis>) sats',
|
||||
icon: 'request',
|
||||
|
||||
roles: {
|
||||
merchant: {
|
||||
name: 'Sell Items',
|
||||
description: 'Receive payment for $(<receiptSummary>)',
|
||||
icon: 'request',
|
||||
requirements: {
|
||||
secrets: ['merchantKey'],
|
||||
variables: [
|
||||
'totalSatoshis',
|
||||
'orderId',
|
||||
'merchantName',
|
||||
'receiptSummary',
|
||||
'lineItemsJson',
|
||||
],
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
name: 'Pay',
|
||||
description: 'Pay $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||
icon: 'send',
|
||||
requirements: {},
|
||||
},
|
||||
role: "merchant",
|
||||
values: {
|
||||
generated: {
|
||||
merchantKey:
|
||||
"KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8",
|
||||
},
|
||||
|
||||
requirements: {
|
||||
participants: [
|
||||
{ role: 'merchant', slots: { min: 1, max: 1 } },
|
||||
{ role: 'customer', slots: { min: 1 } },
|
||||
],
|
||||
variables: {
|
||||
totalSatoshis: 3500,
|
||||
orderId: "order-demo-1",
|
||||
merchantName: "XO Snack Machine",
|
||||
receiptSummary: "2× Cola, 1× Chips",
|
||||
lineItemsJson:
|
||||
'[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]',
|
||||
},
|
||||
|
||||
transaction: 'purchaseItemsTransaction',
|
||||
},
|
||||
},
|
||||
|
||||
transactions: {
|
||||
purchaseItemsTransaction: {
|
||||
name: 'Vending Purchase',
|
||||
description: 'Order $(<orderId>): $(<receiptSummary>)',
|
||||
icon: 'request',
|
||||
|
||||
roles: {
|
||||
merchant: {
|
||||
name: 'Received Payment',
|
||||
description: 'Received $(<totalSatoshis>) sats from $(<merchantName>) sale',
|
||||
icon: 'receive',
|
||||
},
|
||||
customer: {
|
||||
name: 'Sent Payment',
|
||||
description: 'Paid $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||
icon: 'send',
|
||||
},
|
||||
},
|
||||
|
||||
secrets: {},
|
||||
inputs: [],
|
||||
outputs: [{ output: 'purchaseOutput' }],
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
composable: true,
|
||||
},
|
||||
},
|
||||
|
||||
/** No custom input templates — customer UTXOs are selected at funding time. */
|
||||
inputs: {},
|
||||
|
||||
outputs: {
|
||||
changeOutput: {
|
||||
name: 'Change',
|
||||
description: 'Funds returned as change.',
|
||||
icon: 'receive',
|
||||
lockingScript: 'merchantReceivingLockingScript',
|
||||
},
|
||||
purchaseOutput: {
|
||||
name: 'Purchase Payment',
|
||||
description: '$(<totalSatoshis>) sats to $(<merchantName>)',
|
||||
icon: 'request',
|
||||
|
||||
roles: {
|
||||
merchant: {
|
||||
name: 'Payment Received',
|
||||
description: 'Received $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||
},
|
||||
customer: {
|
||||
name: 'Payment Sent',
|
||||
description: 'Sent $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||
},
|
||||
},
|
||||
|
||||
lockingScript: 'merchantReceivingLockingScript',
|
||||
valueSatoshis: '$(<totalSatoshis>)',
|
||||
token: null,
|
||||
},
|
||||
},
|
||||
|
||||
lockingScripts: {
|
||||
merchantReceivingLockingScript: {
|
||||
name: 'Merchant Receive',
|
||||
description: 'Funds received by the vending machine merchant.',
|
||||
icon: 'address',
|
||||
lockingType: 'p2pkh',
|
||||
lockingBytecode: 'lockMerchantP2PKH',
|
||||
unlockingBytecode: 'unlockMerchantP2PKH',
|
||||
actions: [],
|
||||
state: { variables: [], secrets: [] },
|
||||
balance: {},
|
||||
roles: {
|
||||
merchant: {
|
||||
state: {
|
||||
variables: [],
|
||||
secrets: ['merchantKey'],
|
||||
},
|
||||
actions: [],
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
scripts: {
|
||||
lockMerchantP2PKH:
|
||||
'OP_DUP OP_HASH160 <$(<merchantKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG',
|
||||
unlockMerchantP2PKH:
|
||||
'<merchantKey.schnorr_signature.all_outputs> <merchantKey.public_key>',
|
||||
},
|
||||
|
||||
constants: {
|
||||
dustLimit: {
|
||||
name: 'Dust Limit',
|
||||
description: 'Minimum satoshis for P2PKH outputs.',
|
||||
type: 'integer',
|
||||
value: 546,
|
||||
},
|
||||
},
|
||||
|
||||
variables: {
|
||||
merchantKey: {
|
||||
name: 'Merchant Private Key',
|
||||
description: 'Private key for the vending machine merchant wallet.',
|
||||
type: 'bytes',
|
||||
hint: 'private_key',
|
||||
},
|
||||
totalSatoshis: {
|
||||
name: 'Total Price',
|
||||
description: 'Total purchase price in satoshis',
|
||||
type: 'integer',
|
||||
hint: 'satoshis',
|
||||
},
|
||||
orderId: {
|
||||
name: 'Order ID',
|
||||
description: 'Unique order identifier',
|
||||
type: 'string',
|
||||
},
|
||||
merchantName: {
|
||||
name: 'Merchant Name',
|
||||
description: 'Display name of the vending machine',
|
||||
type: 'string',
|
||||
},
|
||||
receiptSummary: {
|
||||
name: 'Receipt Summary',
|
||||
description: 'Human-readable list of purchased items',
|
||||
type: 'string',
|
||||
},
|
||||
lineItemsJson: {
|
||||
name: 'Line Items',
|
||||
description: 'JSON-encoded line items for the purchase',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
|
||||
icons: [
|
||||
{ name: 'wallet', hash: '0000000000000000000000' },
|
||||
{ name: 'owner', hash: '0000000000000000000000' },
|
||||
{ name: 'sender', hash: '0000000000000000000000' },
|
||||
{ name: 'request', hash: '0000000000000000000000' },
|
||||
{ name: 'receive', hash: '0000000000000000000000' },
|
||||
{ name: 'send', hash: '0000000000000000000000' },
|
||||
],
|
||||
|
||||
scenarios: [
|
||||
{
|
||||
name: 'purchase items happy path',
|
||||
description: 'Merchant requests payment for vending machine items.',
|
||||
action: 'purchaseItems',
|
||||
roles: [
|
||||
{
|
||||
role: 'merchant',
|
||||
values: {
|
||||
generated: {
|
||||
merchantKey: 'KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8',
|
||||
},
|
||||
variables: {
|
||||
totalSatoshis: 3500,
|
||||
orderId: 'order-demo-1',
|
||||
merchantName: 'XO Snack Machine',
|
||||
receiptSummary: '2× Cola, 1× Chips',
|
||||
lineItemsJson: '[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]',
|
||||
},
|
||||
secrets: {},
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
lockingBytecode: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac',
|
||||
valueSatoshis: 3500,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
role: 'customer',
|
||||
values: {
|
||||
generated: {},
|
||||
variables: {},
|
||||
secrets: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
},
|
||||
outputs: [
|
||||
{
|
||||
lockingBytecode:
|
||||
"76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
|
||||
valueSatoshis: 3500,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
role: "customer",
|
||||
values: {
|
||||
generated: {},
|
||||
variables: {},
|
||||
secrets: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,266 +1,270 @@
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
|
||||
export const wrapBCHTemplate: XOTemplate = {
|
||||
$schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json',
|
||||
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
|
||||
|
||||
name: 'Wrapped BCH',
|
||||
description: 'Convert between BCH and wBCH tokens.',
|
||||
icon: 'wrap',
|
||||
name: "Wrapped BCH",
|
||||
description: "Convert between BCH and wBCH tokens.",
|
||||
icon: "wrap",
|
||||
|
||||
version: '1',
|
||||
supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'],
|
||||
version: "1",
|
||||
supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
name: 'User',
|
||||
description: 'The person wrapping or unwrapping BCH.',
|
||||
icon: 'user',
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
user: {
|
||||
name: "User",
|
||||
description: "The person wrapping or unwrapping BCH.",
|
||||
icon: "user",
|
||||
},
|
||||
},
|
||||
|
||||
start: [
|
||||
{
|
||||
action: 'wrap',
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
action: 'unwrap',
|
||||
role: 'user',
|
||||
},
|
||||
],
|
||||
start: [
|
||||
{
|
||||
action: "wrap",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
action: "unwrap",
|
||||
role: "user",
|
||||
},
|
||||
],
|
||||
|
||||
actions: {
|
||||
wrap: {
|
||||
name: 'Wrap BCH',
|
||||
description: 'Convert BCH into wBCH tokens.',
|
||||
icon: 'wrap',
|
||||
actions: {
|
||||
wrap: {
|
||||
name: "Wrap BCH",
|
||||
description: "Convert BCH into wBCH tokens.",
|
||||
icon: "wrap",
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
requirements: {
|
||||
variables: ['amountToWrap', 'recipientLockingScript'],
|
||||
},
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
user: {
|
||||
requirements: {
|
||||
variables: ["amountToWrap", "recipientLockingScript"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
requirements: {
|
||||
participants: [{ role: 'user', slots: { min: 1, max: 1 } }],
|
||||
},
|
||||
requirements: {
|
||||
participants: [{ role: "user", slots: { min: 1, max: 1 } }],
|
||||
},
|
||||
|
||||
transaction: 'wrapTransaction',
|
||||
},
|
||||
transaction: "wrapTransaction",
|
||||
},
|
||||
|
||||
unwrap: {
|
||||
name: 'Unwrap wBCH',
|
||||
description: 'Convert wBCH tokens back into BCH.',
|
||||
icon: 'unwrap',
|
||||
unwrap: {
|
||||
name: "Unwrap wBCH",
|
||||
description: "Convert wBCH tokens back into BCH.",
|
||||
icon: "unwrap",
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
requirements: {
|
||||
variables: ['amountToUnwrap', 'recipientLockingScript'],
|
||||
},
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
user: {
|
||||
requirements: {
|
||||
variables: ["amountToUnwrap", "recipientLockingScript"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
requirements: {
|
||||
participants: [{ role: 'user', slots: { min: 1, max: 1 } }],
|
||||
},
|
||||
requirements: {
|
||||
participants: [{ role: "user", slots: { min: 1, max: 1 } }],
|
||||
},
|
||||
|
||||
transaction: 'unwrapTransaction',
|
||||
},
|
||||
},
|
||||
transaction: "unwrapTransaction",
|
||||
},
|
||||
},
|
||||
|
||||
transactions: {
|
||||
wrapTransaction: {
|
||||
name: 'Wrapped BCH',
|
||||
description: 'Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) BCH into wBCH tokens.',
|
||||
icon: 'wrap',
|
||||
transactions: {
|
||||
wrapTransaction: {
|
||||
name: "Wrapped BCH",
|
||||
description:
|
||||
"Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) BCH into wBCH tokens.",
|
||||
icon: "wrap",
|
||||
|
||||
inputs: [
|
||||
{ input: 'covenantInput', inputIndex: 0 },
|
||||
],
|
||||
outputs: [
|
||||
{ output: 'covenantOutput', outputIndex: 0 },
|
||||
{ output: 'wrappedTokensOutput', outputIndex: undefined },
|
||||
],
|
||||
inputs: [{ input: "covenantInput", inputIndex: 0 }],
|
||||
outputs: [
|
||||
{ output: "covenantOutput", outputIndex: 0 },
|
||||
{ output: "wrappedTokensOutput", outputIndex: undefined },
|
||||
],
|
||||
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
composable: true,
|
||||
},
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
composable: true,
|
||||
},
|
||||
|
||||
unwrapTransaction: {
|
||||
name: 'Unwrapped wBCH',
|
||||
description: 'Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) wBCH tokens back into BCH.',
|
||||
icon: 'unwrap',
|
||||
unwrapTransaction: {
|
||||
name: "Unwrapped wBCH",
|
||||
description:
|
||||
"Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) wBCH tokens back into BCH.",
|
||||
icon: "unwrap",
|
||||
|
||||
inputs: [
|
||||
{ input: 'covenantInput', inputIndex: 0 },
|
||||
],
|
||||
outputs: [
|
||||
{ output: 'covenantOutput', outputIndex: 0 },
|
||||
{ output: 'unwrappedSatoshisOutput', outputIndex: undefined },
|
||||
],
|
||||
inputs: [{ input: "covenantInput", inputIndex: 0 }],
|
||||
outputs: [
|
||||
{ output: "covenantOutput", outputIndex: 0 },
|
||||
{ output: "unwrappedSatoshisOutput", outputIndex: undefined },
|
||||
],
|
||||
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
composable: true,
|
||||
},
|
||||
},
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
composable: true,
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
covenantOutput: {
|
||||
name: 'wBCH Covenant',
|
||||
description: 'Holds BCH and wBCH tokens that can be freely converted.',
|
||||
icon: 'contract',
|
||||
outputs: {
|
||||
covenantOutput: {
|
||||
name: "wBCH Covenant",
|
||||
description: "Holds BCH and wBCH tokens that can be freely converted.",
|
||||
icon: "contract",
|
||||
|
||||
lockingScript: 'wrapBCHLockingScript',
|
||||
},
|
||||
lockingScript: "wrapBCHLockingScript",
|
||||
},
|
||||
|
||||
wrappedTokensOutput: {
|
||||
name: 'Wrapped wBCH',
|
||||
description: 'Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.',
|
||||
icon: 'receive',
|
||||
wrappedTokensOutput: {
|
||||
name: "Wrapped wBCH",
|
||||
description:
|
||||
"Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.",
|
||||
icon: "receive",
|
||||
|
||||
valueSatoshis: '$(<amountToWrap>)',
|
||||
token: {
|
||||
category: '$(<wbchTokenCategory>)',
|
||||
amount: '$(<amountToWrap>)',
|
||||
nft: null,
|
||||
},
|
||||
valueSatoshis: "$(<amountToWrap>)",
|
||||
token: {
|
||||
category: "$(<wbchTokenCategory>)",
|
||||
amount: "$(<amountToWrap>)",
|
||||
nft: null,
|
||||
},
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
user: {
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
|
||||
lockingScript: '$(<recipientLockingScript>)',
|
||||
},
|
||||
lockingScript: "$(<recipientLockingScript>)",
|
||||
},
|
||||
|
||||
unwrappedSatoshisOutput: {
|
||||
name: 'Unwrapped BCH',
|
||||
description: 'Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.',
|
||||
icon: 'receive',
|
||||
unwrappedSatoshisOutput: {
|
||||
name: "Unwrapped BCH",
|
||||
description:
|
||||
"Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.",
|
||||
icon: "receive",
|
||||
|
||||
valueSatoshis: '$(<amountToUnwrap>)',
|
||||
token: null,
|
||||
valueSatoshis: "$(<amountToUnwrap>)",
|
||||
token: null,
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
user: {
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
|
||||
lockingScript: '$(<recipientLockingScript>)',
|
||||
},
|
||||
},
|
||||
lockingScript: "$(<recipientLockingScript>)",
|
||||
},
|
||||
},
|
||||
|
||||
inputs: {
|
||||
covenantInput: {
|
||||
name: 'wBCH Covenant',
|
||||
description: 'The covenant being updated.',
|
||||
icon: 'contract',
|
||||
inputs: {
|
||||
covenantInput: {
|
||||
name: "wBCH Covenant",
|
||||
description: "The covenant being updated.",
|
||||
icon: "contract",
|
||||
|
||||
unlockingScript: 'unlockCovenant',
|
||||
},
|
||||
},
|
||||
unlockingScript: "unlockCovenant",
|
||||
},
|
||||
},
|
||||
|
||||
lockingScripts: {
|
||||
wrapBCHLockingScript: {
|
||||
name: 'wBCH Covenant',
|
||||
description: 'Holds BCH and wBCH tokens that can be freely converted.',
|
||||
icon: 'contract',
|
||||
lockingScripts: {
|
||||
wrapBCHLockingScript: {
|
||||
name: "wBCH Covenant",
|
||||
description: "Holds BCH and wBCH tokens that can be freely converted.",
|
||||
icon: "contract",
|
||||
|
||||
lockingType: 'p2sh',
|
||||
lockingBytecode: 'wrapBCHLockingBytecode',
|
||||
lockingType: "p2sh",
|
||||
lockingBytecode: "wrapBCHLockingBytecode",
|
||||
|
||||
actions: [
|
||||
{ action: 'wrap', role: 'user' },
|
||||
{ action: 'unwrap', role: 'user' },
|
||||
],
|
||||
actions: [
|
||||
{ action: "wrap", role: "user" },
|
||||
{ action: "unwrap", role: "user" },
|
||||
],
|
||||
|
||||
state: {
|
||||
variables: [],
|
||||
secrets: [],
|
||||
},
|
||||
balance: {
|
||||
satoshis: 0n,
|
||||
fungibleTokens: 0n,
|
||||
},
|
||||
selectable: false,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
variables: [],
|
||||
secrets: [],
|
||||
},
|
||||
balance: {
|
||||
satoshis: 0n,
|
||||
fungibleTokens: 0n,
|
||||
},
|
||||
selectable: false,
|
||||
},
|
||||
},
|
||||
|
||||
scripts: {
|
||||
enforceCovenantPersists: 'OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY',
|
||||
enforceTokenCategoryPreserved: 'OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY',
|
||||
enforceValueTokenSumConserved: 'OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY',
|
||||
scripts: {
|
||||
enforceCovenantPersists:
|
||||
"OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY",
|
||||
enforceTokenCategoryPreserved:
|
||||
"OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY",
|
||||
enforceValueTokenSumConserved:
|
||||
"OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY",
|
||||
|
||||
// Direct script references — introspection opcodes must not use $(...) evaluations
|
||||
// because those are evaluated at compile time without transaction context.
|
||||
wrapBCHLockingBytecode: 'enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved',
|
||||
unlockCovenant: '',
|
||||
},
|
||||
// Direct script references — introspection opcodes must not use $(...) evaluations
|
||||
// because those are evaluated at compile time without transaction context.
|
||||
wrapBCHLockingBytecode:
|
||||
"enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved",
|
||||
unlockCovenant: "",
|
||||
},
|
||||
|
||||
constants: {
|
||||
wbchTokenCategory: {
|
||||
name: 'wBCH Token Category',
|
||||
description: 'The official token category for Wrapped BCH.',
|
||||
type: 'bytes',
|
||||
value: 'ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3',
|
||||
},
|
||||
satoshisPerBCH: {
|
||||
name: 'Satoshis per BCH',
|
||||
description: 'Used to display amounts in BCH with decimals.',
|
||||
type: 'integer',
|
||||
value: 100000000,
|
||||
},
|
||||
tokenDust: {
|
||||
name: 'Token Dust Limit',
|
||||
description: 'Minimal satoshis required for a token-bearing output.',
|
||||
type: 'integer',
|
||||
value: 1000,
|
||||
},
|
||||
},
|
||||
constants: {
|
||||
wbchTokenCategory: {
|
||||
name: "wBCH Token Category",
|
||||
description: "The official token category for Wrapped BCH.",
|
||||
type: "bytes",
|
||||
value: "ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3",
|
||||
},
|
||||
satoshisPerBCH: {
|
||||
name: "Satoshis per BCH",
|
||||
description: "Used to display amounts in BCH with decimals.",
|
||||
type: "integer",
|
||||
value: 100000000,
|
||||
},
|
||||
tokenDust: {
|
||||
name: "Token Dust Limit",
|
||||
description: "Minimal satoshis required for a token-bearing output.",
|
||||
type: "integer",
|
||||
value: 1000,
|
||||
},
|
||||
},
|
||||
|
||||
variables: {
|
||||
amountToWrap: {
|
||||
name: 'Amount to Wrap',
|
||||
description: 'How much BCH to convert to wBCH (in satoshis).',
|
||||
type: 'integer',
|
||||
hint: 'satoshis',
|
||||
},
|
||||
amountToUnwrap: {
|
||||
name: 'Amount to Unwrap',
|
||||
description: 'How much wBCH to convert back to BCH (in satoshis).',
|
||||
type: 'integer',
|
||||
hint: 'satoshis',
|
||||
},
|
||||
recipientLockingScript: {
|
||||
name: 'Destination',
|
||||
description: 'Where to receive your BCH or wBCH tokens.',
|
||||
type: 'bytes',
|
||||
hint: 'lockingScript',
|
||||
},
|
||||
},
|
||||
variables: {
|
||||
amountToWrap: {
|
||||
name: "Amount to Wrap",
|
||||
description: "How much BCH to convert to wBCH (in satoshis).",
|
||||
type: "integer",
|
||||
hint: "satoshis",
|
||||
},
|
||||
amountToUnwrap: {
|
||||
name: "Amount to Unwrap",
|
||||
description: "How much wBCH to convert back to BCH (in satoshis).",
|
||||
type: "integer",
|
||||
hint: "satoshis",
|
||||
},
|
||||
recipientLockingScript: {
|
||||
name: "Destination",
|
||||
description: "Where to receive your BCH or wBCH tokens.",
|
||||
type: "bytes",
|
||||
hint: "lockingScript",
|
||||
},
|
||||
},
|
||||
|
||||
icons: [
|
||||
{ name: 'wrap', hash: '0000000000000000000000' },
|
||||
{ name: 'unwrap', hash: '0000000000000000000000' },
|
||||
{ name: 'user', hash: '0000000000000000000000' },
|
||||
{ name: 'contract', hash: '0000000000000000000000' },
|
||||
{ name: 'receive', hash: '0000000000000000000000' },
|
||||
],
|
||||
icons: [
|
||||
{ name: "wrap", hash: "0000000000000000000000" },
|
||||
{ name: "unwrap", hash: "0000000000000000000000" },
|
||||
{ name: "user", hash: "0000000000000000000000" },
|
||||
{ name: "contract", hash: "0000000000000000000000" },
|
||||
{ name: "receive", hash: "0000000000000000000000" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useAppContext } from './useAppContext.js';
|
||||
|
||||
/**
|
||||
* Get all invitations reactively.
|
||||
* Re-renders when invitations are added or removed.
|
||||
* Re-renders when invitations are added, removed, or updated.
|
||||
*/
|
||||
export function useInvitations(): Invitation[] {
|
||||
const { appService } = useAppContext();
|
||||
@@ -21,26 +21,22 @@ export function useInvitations(): Invitation[] {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// Subscribe to invitation list changes
|
||||
const onAdded = () => callback();
|
||||
const onRemoved = () => callback();
|
||||
|
||||
appService.on('invitation-added', onAdded);
|
||||
appService.on('invitation-removed', onRemoved);
|
||||
appService.on('wallet-state-changed', callback);
|
||||
|
||||
return () => {
|
||||
appService.off('invitation-added', onAdded);
|
||||
appService.off('invitation-removed', onRemoved);
|
||||
appService.off('wallet-state-changed', callback);
|
||||
};
|
||||
},
|
||||
[appService]
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
return appService?.invitations ?? [];
|
||||
return appService?.invitationsRevision ?? 0;
|
||||
}, [appService]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const revision = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
return useMemo(() => [...(appService?.invitations ?? [])], [appService, revision]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,48 +52,41 @@ export function useInvitation(invitationId: string | null): Invitation | null {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// Find the invitation instance
|
||||
const invitation = appService.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === invitationId
|
||||
);
|
||||
|
||||
if (!invitation) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// Subscribe to this specific invitation's updates
|
||||
const onUpdated = () => callback();
|
||||
const onStatusChanged = () => callback();
|
||||
|
||||
invitation.on('invitation-updated', onUpdated);
|
||||
invitation.on('invitation-status-changed', onStatusChanged);
|
||||
|
||||
// Also subscribe to list changes in case the invitation is removed
|
||||
const onRemoved = () => callback();
|
||||
appService.on('invitation-removed', onRemoved);
|
||||
const onWalletStateChanged = ({
|
||||
invitationIdentifier,
|
||||
}: {
|
||||
invitationIdentifier: string;
|
||||
}) => {
|
||||
if (invitationIdentifier === invitationId) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
appService.on('wallet-state-changed', onWalletStateChanged);
|
||||
|
||||
return () => {
|
||||
invitation.off('invitation-updated', onUpdated);
|
||||
invitation.off('invitation-status-changed', onStatusChanged);
|
||||
appService.off('invitation-removed', onRemoved);
|
||||
appService.off('wallet-state-changed', onWalletStateChanged);
|
||||
};
|
||||
},
|
||||
[appService, invitationId]
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!appService || !invitationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
appService.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === invitationId
|
||||
) ?? null
|
||||
);
|
||||
return appService && invitationId
|
||||
? appService.getInvitationRevision(invitationId)
|
||||
: 0;
|
||||
}, [appService, invitationId]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
if (!appService || !invitationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
appService.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === invitationId
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +98,7 @@ export function useInvitationData(invitationId: string | null): XOInvitation | n
|
||||
|
||||
return useMemo(() => {
|
||||
return invitation?.data ?? null;
|
||||
}, [invitation?.data.invitationIdentifier, invitation?.data.commits?.length]);
|
||||
}, [invitation?.data]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,7 +44,7 @@ interface MnemonicFileEntry {
|
||||
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'generateRandomSeed' | 'button';
|
||||
|
||||
/**
|
||||
* Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
|
||||
* Reads mnemonic-* files from the configured mnemonics directory (same as xo-cli),
|
||||
* then from cwd for legacy installs. Parses each as a BCHMnemonicURL.
|
||||
*/
|
||||
function loadMnemonicFiles(): MnemonicFileEntry[] {
|
||||
@@ -101,7 +101,7 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
|
||||
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
||||
|
||||
/** When set, manual seed is written to ~/.config/xo-cli/mnemonics/ after a successful unlock. */
|
||||
/** When set, manual seed is written to the configured mnemonics directory after a successful unlock. */
|
||||
const [saveMnemonicChecked, setSaveMnemonicChecked] = useState(false);
|
||||
|
||||
// Focus: when saved wallets exist default to the file list, otherwise the input.
|
||||
@@ -397,7 +397,7 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
{saveMnemonicChecked ? '[x] ' : '[ ] '}
|
||||
</Text>
|
||||
<Text color={colors.text}>Save this mnemonic</Text>
|
||||
<Text color={colors.textMuted}> (~/.config/xo-cli/mnemonics/)</Text>
|
||||
<Text color={colors.textMuted}> ({getMnemonicsDir()}/)</Text>
|
||||
</Box>
|
||||
{focusedSection === 'saveCheckbox' && (
|
||||
<Box marginTop={0} paddingX={1}>
|
||||
|
||||
@@ -7,4 +7,3 @@ export { SeedInputScreen } from './SeedInput.js';
|
||||
export { WalletStateScreen } from './WalletState.js';
|
||||
export { TemplateListScreen } from './TemplateList.js';
|
||||
export { InvitationScreen } from './invitations/InvitationScreen.js';
|
||||
export { TransactionScreen } from './Transaction.js';
|
||||
|
||||
@@ -751,7 +751,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, XOInvitationVariableValue>)
|
||||
}, {} as Record<string, XOInvitationVariableValue>),
|
||||
evaluationDecodeMode: 'bigint'
|
||||
})}
|
||||
|
||||
{/* Output value */}
|
||||
|
||||
@@ -13,30 +13,36 @@ const execAsync = promisify(exec);
|
||||
// The command is a function that returns a promise that resolves to the result of the command.
|
||||
const clipboardMethods = {
|
||||
pbCopy: {
|
||||
platform: (platform: string) => platform === 'darwin',
|
||||
command: async (text: string) => execAsync(`printf '%s' '${text}' | pbcopy`),
|
||||
platform: (platform: string) => platform === "darwin",
|
||||
command: async (text: string) =>
|
||||
execAsync(`printf '%s' '${text}' | pbcopy`),
|
||||
},
|
||||
xclip: {
|
||||
platform: (platform: string) => platform === 'linux',
|
||||
command: async (text: string) => execAsync(`printf '%s' '${text}' | xclip -selection clipboard`),
|
||||
platform: (platform: string) => platform === "linux",
|
||||
command: async (text: string) =>
|
||||
execAsync(`printf '%s' '${text}' | xclip -selection clipboard`),
|
||||
},
|
||||
xsel: {
|
||||
platform: (platform: string) => platform === 'linux',
|
||||
command: async (text: string) => execAsync(`printf '%s' '${text}' | xsel --clipboard --input`),
|
||||
platform: (platform: string) => platform === "linux",
|
||||
command: async (text: string) =>
|
||||
execAsync(`printf '%s' '${text}' | xsel --clipboard --input`),
|
||||
},
|
||||
ssh: {
|
||||
platform: (platform: string) => platform === 'linux',
|
||||
command: async (text: string) => process.stdout.write(`\x1b]52;c;${Buffer.from(text, 'utf-8').toString('base64')}\x07`),
|
||||
platform: (platform: string) => platform === "linux",
|
||||
command: async (text: string) =>
|
||||
process.stdout.write(
|
||||
`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`,
|
||||
),
|
||||
},
|
||||
clip: {
|
||||
platform: (platform: string) => platform === 'windows',
|
||||
platform: (platform: string) => platform === "windows",
|
||||
command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`),
|
||||
},
|
||||
clipboardy: {
|
||||
platform: (platform: string) => platform === 'windows',
|
||||
platform: (platform: string) => platform === "windows",
|
||||
command: async (text: string) => clipboardy.writeSync(text),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to copy text to clipboard using multiple methods.
|
||||
@@ -51,7 +57,9 @@ export async function copyToClipboard(text: string): Promise<void> {
|
||||
// Escape the text for shell commands
|
||||
const escapedText = text.replace(/'/g, "'\\''");
|
||||
|
||||
const availableMethods = Object.values(clipboardMethods).filter(method => method.platform(platform));
|
||||
const availableMethods = Object.values(clipboardMethods).filter((method) =>
|
||||
method.platform(platform),
|
||||
);
|
||||
|
||||
const errors: Error[] = [];
|
||||
|
||||
@@ -63,7 +71,7 @@ export async function copyToClipboard(text: string): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
} catch(error) {
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
errors.push(error);
|
||||
}
|
||||
@@ -71,5 +79,7 @@ export async function copyToClipboard(text: string): Promise<void> {
|
||||
}
|
||||
|
||||
// All methods failed
|
||||
throw new Error(`Clipboard not available. ${errors.map(error => error.message).join('\n')}`);
|
||||
throw new Error(
|
||||
`Clipboard not available. ${errors.map((error) => error.message).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,8 +160,7 @@ export function listDirectoryEntries(
|
||||
entries: [...entries, ...directories, ...files],
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
entries: [],
|
||||
error: `Unable to read directory: ${message}`,
|
||||
|
||||
@@ -51,7 +51,7 @@ export function buildHistoryDisplayRows(
|
||||
type: "history_output",
|
||||
label: output.outpoint
|
||||
? `${output.outpoint.txid}:${output.outpoint.index}`
|
||||
: output.outputIdentifier ?? "Output",
|
||||
: (output.outputIdentifier ?? "Output"),
|
||||
description: `${item.template} | ${roles} | ${output.description}`,
|
||||
timestamp: item.createdAtTimestamp,
|
||||
isNested: false,
|
||||
@@ -96,7 +96,7 @@ export function buildHistoryDisplayRows(
|
||||
type: "history_output",
|
||||
label: output.outpoint
|
||||
? `${output.outpoint.txid}:${output.outpoint.index}`
|
||||
: output.outputIdentifier ?? "Output",
|
||||
: (output.outputIdentifier ?? "Output"),
|
||||
description: output.description,
|
||||
isNested: true,
|
||||
valueSatoshis: output.valueSatoshis,
|
||||
|
||||
@@ -65,8 +65,18 @@ export const roleRequiresInputs = (
|
||||
|
||||
const actionRole = action.roles?.[roleIdentifier];
|
||||
const actionRequirements = action.requirements;
|
||||
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleIdentifier);
|
||||
const roleSlotsMin = actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 ? actionRoleRequirements.slots.min : 0;
|
||||
const actionRoleRequirements =
|
||||
actionRole &&
|
||||
actionRequirements &&
|
||||
actionRequirements.participants?.find(
|
||||
(participant) => participant.role === roleIdentifier,
|
||||
);
|
||||
const roleSlotsMin =
|
||||
actionRoleRequirements &&
|
||||
actionRoleRequirements.slots &&
|
||||
actionRoleRequirements.slots.min > 0
|
||||
? actionRoleRequirements.slots.min
|
||||
: 0;
|
||||
if (roleSlotsMin > 0) return true;
|
||||
|
||||
const transactionIdentifier = action.transaction;
|
||||
@@ -78,7 +88,6 @@ export const roleRequiresInputs = (
|
||||
return (roleInputs?.length ?? 0) > 0;
|
||||
};
|
||||
|
||||
|
||||
export const getTransactionOutputIdentifier = (
|
||||
output: XOTemplateTransactionOutput,
|
||||
): string | undefined => {
|
||||
@@ -136,7 +145,8 @@ export const resolveProvidedLockingBytecodeHex = (
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lockingScriptDefinition = template.lockingScripts?.[outputDefinition.lockingScript];
|
||||
const lockingScriptDefinition =
|
||||
template.lockingScripts?.[outputDefinition.lockingScript];
|
||||
const scriptIdentifier = lockingScriptDefinition?.lockingBytecode;
|
||||
if (!scriptIdentifier) return undefined;
|
||||
|
||||
|
||||
@@ -71,12 +71,7 @@ function resolveTemplateModuleLoaderPath(): string {
|
||||
}
|
||||
|
||||
/** TypeScript extensions that require tsx to evaluate the template module. */
|
||||
const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
]);
|
||||
const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
|
||||
|
||||
/**
|
||||
* Loads a TS/JS template module in an isolated child process.
|
||||
@@ -155,7 +150,9 @@ async function loadTemplateModuleViaChildProcess(
|
||||
}
|
||||
|
||||
if (stdout.trim().length === 0) {
|
||||
reject(new TemplateLoadError("Template module loader returned no output."));
|
||||
reject(
|
||||
new TemplateLoadError("Template module loader returned no output."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,7 +171,9 @@ export async function loadTemplateFromFile(filePath: string): Promise<string> {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
throw new TemplateLoadError(`Template file does not exist: ${absolutePath}`);
|
||||
throw new TemplateLoadError(
|
||||
`Template file does not exist: ${absolutePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const extension = path.extname(absolutePath).toLowerCase();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Global XO CLI config layout (XDG-style: ~/.config/xo-cli/).
|
||||
* Global XO CLI config layout (`XO_CONFIG_DIR` or ~/.config/xo-cli/).
|
||||
* User-provided paths (templates, invitation JSON) stay relative to cwd.
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,8 @@ import { basename, isAbsolute, join, resolve } from "node:path";
|
||||
* Base config directory. Created on first access.
|
||||
*/
|
||||
export function getConfigDir(): string {
|
||||
const dir = join(homedir(), ".config", "xo-cli");
|
||||
const dir =
|
||||
process.env["XO_CONFIG_DIR"] || join(homedir(), ".config", "xo-cli");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
@@ -50,7 +51,7 @@ export function getWalletConfigPath(): string {
|
||||
|
||||
/**
|
||||
* Resolves a mnemonic reference to an absolute path.
|
||||
* Order: absolute path if it exists → path relative to cwd → ~/.config/xo-cli/mnemonics/<basename>.
|
||||
* Order: absolute path if it exists → path relative to cwd → config mnemonics directory/<basename>.
|
||||
*
|
||||
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
|
||||
* @returns Absolute path to the mnemonic file
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* Returns true when `value` looks like an XOTemplate object (pre-schema check).
|
||||
* Used only to pick the correct export before {@link parseTemplate} validates fully.
|
||||
*/
|
||||
export function isTemplateLike(value: unknown): value is Record<string, unknown> {
|
||||
export function isTemplateLike(
|
||||
value: unknown,
|
||||
): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EventEmitter } from '../event-emitter.js';
|
||||
import { EventEmitter } from "../event-emitter.js";
|
||||
|
||||
/**
|
||||
* Events emitted by our Rates Adapters
|
||||
@@ -44,14 +44,15 @@ export abstract class BaseRates<
|
||||
BCH: 8,
|
||||
USD: 2,
|
||||
};
|
||||
const minimumFractionDigits = minimumFractionDigitsMap[normalizedCurrency] ?? 2;
|
||||
const minimumFractionDigits =
|
||||
minimumFractionDigitsMap[normalizedCurrency] ?? 2;
|
||||
const maximumFractionDigits = Math.max(minimumFractionDigits, 8);
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
const formatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: normalizedCurrency,
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
currencyDisplay: "narrowSymbol",
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
});
|
||||
@@ -61,7 +62,7 @@ export abstract class BaseRates<
|
||||
// Some numerator symbols from oracle pairs (e.g. DOGE/BCH) are not ISO-4217
|
||||
// fiat currency codes, so Intl currency formatting will throw a RangeError.
|
||||
// In that case we still return a human-readable formatted value.
|
||||
const numericFormatter = new Intl.NumberFormat('en-US', {
|
||||
const numericFormatter = new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
});
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
OracleMetadataMessage,
|
||||
OraclePriceMessage,
|
||||
type OracleMetadataMap,
|
||||
} from '@generalprotocols/oracle-client';
|
||||
} from "@generalprotocols/oracle-client";
|
||||
|
||||
import { type RatesEventMap, BaseRates } from './base-rates.js';
|
||||
import { type OffCallback } from '../event-emitter.js';
|
||||
import { SettingsService } from '../../services/settings.js';
|
||||
import { type RatesEventMap, BaseRates } from "./base-rates.js";
|
||||
import { type OffCallback } from "../event-emitter.js";
|
||||
import { SettingsService } from "../../services/settings.js";
|
||||
|
||||
// Add the Oracle Price Message to our Events for this Adapter.
|
||||
export type RatesOracleEventMap = RatesEventMap & {
|
||||
@@ -42,7 +42,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
|
||||
private started: boolean = false;
|
||||
private targetNumeratorUnitCode: string;
|
||||
private targetDenominatorUnitCode: string = 'BCH';
|
||||
private targetDenominatorUnitCode: string = "BCH";
|
||||
private unsubscribeFromSettings: OffCallback | null = null;
|
||||
|
||||
public constructor(client: OracleClient, settings: SettingsService) {
|
||||
@@ -63,7 +63,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
}
|
||||
this.started = true;
|
||||
this.unsubscribeFromSettings = this.settings.on(
|
||||
'settings-updated',
|
||||
"settings-updated",
|
||||
this.handleSettingsUpdated.bind(this),
|
||||
);
|
||||
|
||||
@@ -150,7 +150,11 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
this.handlePriceMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing prices for oracle:', oracle.publicKey, error);
|
||||
console.error(
|
||||
"Error refreshing prices for oracle:",
|
||||
oracle.publicKey,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -183,8 +187,10 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceNumeratorUnitCode = oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase();
|
||||
const sourceDenominatorUnitCode = oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase();
|
||||
const sourceNumeratorUnitCode =
|
||||
oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase();
|
||||
const sourceDenominatorUnitCode =
|
||||
oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase();
|
||||
|
||||
// Only emit the pair currently selected in settings.
|
||||
if (
|
||||
@@ -197,7 +203,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
// Scale the price
|
||||
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
|
||||
|
||||
this.emit('rateUpdated', {
|
||||
this.emit("rateUpdated", {
|
||||
numeratorUnitCode: sourceNumeratorUnitCode,
|
||||
denominatorUnitCode: sourceDenominatorUnitCode,
|
||||
price: priceValue,
|
||||
@@ -208,13 +214,11 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
/**
|
||||
* Tracks updates to settings and switches the actively emitted fiat pair.
|
||||
*/
|
||||
private handleSettingsUpdated(
|
||||
event: {
|
||||
key: 'currency' | 'default-mnemonic';
|
||||
value: string | undefined;
|
||||
},
|
||||
) {
|
||||
if (event.key !== 'currency' || !event.value) {
|
||||
private handleSettingsUpdated(event: {
|
||||
key: "currency" | "default-mnemonic";
|
||||
value: string | undefined;
|
||||
}) {
|
||||
if (event.key !== "currency" || !event.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,7 +227,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||
// Refresh so listeners get the latest value for the new currency quickly.
|
||||
if (this.started) {
|
||||
this.refreshPrices().catch((error) => {
|
||||
console.error('Error refreshing prices after currency update:', error);
|
||||
console.error("Error refreshing prices after currency update:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { XOInvitation } from "@xo-cash/types";
|
||||
import { EventEmitter } from "./event-emitter.js";
|
||||
// import { SSESession, type SSEvent } from "./sse-client.js";
|
||||
import { SSESession, type SSEvent } from "@xo-cash/utils";
|
||||
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
|
||||
|
||||
function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation {
|
||||
const { entityIdentifier: _entityIdentifier, ...sharedInvitation } =
|
||||
invitation as XOInvitation & { entityIdentifier?: string };
|
||||
|
||||
return sharedInvitation;
|
||||
}
|
||||
|
||||
export type SyncServerEventMap = {
|
||||
connected: void;
|
||||
disconnected: void;
|
||||
@@ -21,62 +27,66 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
return server;
|
||||
}
|
||||
|
||||
private sse: SSESession;
|
||||
private sse: SSESession | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
private readonly invitationIdentifier: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
// Create an SSE Session
|
||||
this.sse = new SSESession(
|
||||
`${baseUrl}/invitations?invitationIdentifier=${invitationIdentifier}`,
|
||||
async connect(): Promise<void> {
|
||||
if (this.sse) {
|
||||
await this.sse.connect();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.createSSESession();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.sse?.disconnect();
|
||||
this.sse = null;
|
||||
}
|
||||
|
||||
private async createSSESession(): Promise<void> {
|
||||
const sse = await SSESession.create(
|
||||
`${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(this.invitationIdentifier)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
|
||||
// Create our event bubblers
|
||||
onError: (error: unknown) =>
|
||||
persistent: true,
|
||||
onRequest: async (request) => {
|
||||
const { body: _body, ...requestWithoutBody } = request;
|
||||
return requestWithoutBody;
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
),
|
||||
onDisconnected: () => this.emit("disconnected", undefined),
|
||||
onConnected: () => this.emit("connected", undefined),
|
||||
);
|
||||
},
|
||||
onDisconnected: () => {
|
||||
this.emit("disconnected", undefined);
|
||||
},
|
||||
onConnected: () => {
|
||||
this.emit("connected", undefined);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.sse.on("message", (event: SSEvent) => this.emit("message", event));
|
||||
this.sse = sse;
|
||||
sse.on("message", (event: SSEvent) => {
|
||||
this.emit("message", event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the sync server.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
// Connect to the SSE Session
|
||||
await this.sse.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the sync server.
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
// Disconnect from the SSE Session
|
||||
await this.sse.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the invitation by identifier.
|
||||
* @param identifier - The invitation identifier.
|
||||
* @returns The invitation.
|
||||
*/
|
||||
async getInvitation(identifier: string): Promise<XOInvitation | undefined> {
|
||||
// Send a GET request to the sync server
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/invitations?invitationIdentifier=${identifier}`,
|
||||
`${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(identifier)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -84,33 +94,23 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
}
|
||||
|
||||
const invitation = deserializeInvitation(await response.text());
|
||||
return invitation;
|
||||
return stripLocalInvitationMetadata(invitation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an invitation.
|
||||
* @param invitation - The invitation to create.
|
||||
* @returns The invitation.
|
||||
*/
|
||||
async publishInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||
// Send a POST request to the sync server
|
||||
const response = await fetch(`${this.baseUrl}/invitations`, {
|
||||
method: "POST",
|
||||
body: serializeInvitation(invitation),
|
||||
body: serializeInvitation(stripLocalInvitationMetadata(invitation)),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Throw is there was an issue with the request
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to publish invitation: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Read the returned JSON
|
||||
// TODO: This should use zod to verify the response
|
||||
const data = deserializeInvitation(await response.text());
|
||||
|
||||
return data;
|
||||
return stripLocalInvitationMetadata(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ try {
|
||||
const template = pickTemplateExport(loadedModule);
|
||||
process.stdout.write(serializeTemplate(template as XOTemplate));
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Failed to load template module: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -188,13 +188,13 @@ export function getRolesForAction(
|
||||
);
|
||||
|
||||
return startEntries.map((entry) => {
|
||||
const roleDef = template.roles?.[entry.role || ''];
|
||||
const roleDef = template.roles?.[entry.role || ""];
|
||||
const roleObj = typeof roleDef === "object" ? roleDef : null;
|
||||
|
||||
// TODO: This is ugly. Lot of conditionals. Need to take a much closer look at this.
|
||||
return {
|
||||
roleId: entry.role || '',
|
||||
name: roleObj?.name || entry.role || '',
|
||||
roleId: entry.role || "",
|
||||
name: roleObj?.name || entry.role || "",
|
||||
description: roleObj?.description,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ export type UnspentOutputMetadata = {
|
||||
outputIdentifier?: string;
|
||||
};
|
||||
|
||||
export type UnspentOutputWithMetadata = UnspentOutputData & UnspentOutputMetadata;
|
||||
export type UnspentOutputWithMetadata = UnspentOutputData &
|
||||
UnspentOutputMetadata;
|
||||
|
||||
/**
|
||||
* Builds a lookup map from script hash to its stored metadata.
|
||||
|
||||
114
tests/cli/autocomplete-completions.test.ts
Normal file
114
tests/cli/autocomplete-completions.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
generateBashCompletions,
|
||||
generateFishCompletions,
|
||||
generateZshCompletions,
|
||||
installCompletions,
|
||||
} from "../../src/cli/autocomplete/completions";
|
||||
|
||||
describe("shell completions", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
function createConfigFile(contents = ""): string {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "xo-cli-completions-test-"));
|
||||
tempDirs.push(tempDir);
|
||||
const configFile = join(tempDir, "shellrc");
|
||||
writeFileSync(configFile, contents);
|
||||
return configFile;
|
||||
}
|
||||
|
||||
test("uses shell-native mnemonic completion in bash", () => {
|
||||
const completions = generateBashCompletions("xo-cli");
|
||||
|
||||
expect(completions).toContain(
|
||||
'local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"',
|
||||
);
|
||||
expect(completions).toContain('__xo_complete_mnemonics "${cur}"');
|
||||
expect(completions).not.toContain('__xo_complete mnemonics "${cur}"');
|
||||
});
|
||||
|
||||
test("uses shell-native mnemonic completion in zsh", () => {
|
||||
const completions = generateZshCompletions("xo-cli");
|
||||
|
||||
expect(completions).toContain(
|
||||
'local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"',
|
||||
);
|
||||
expect(completions).toContain(
|
||||
'__xo_complete_mnemonics "${words[CURRENT]}"',
|
||||
);
|
||||
expect(completions).not.toContain(
|
||||
'__xo_complete mnemonics "${words[CURRENT]}"',
|
||||
);
|
||||
});
|
||||
|
||||
test("uses shell-native mnemonic completion in fish", () => {
|
||||
const completions = generateFishCompletions("xo-cli");
|
||||
|
||||
expect(completions).toContain('set -l config_dir "$XO_CONFIG_DIR"');
|
||||
expect(completions).toContain("(__xo_cli_complete_mnemonics)");
|
||||
expect(completions).not.toContain("(__xo_cli_complete_dynamic mnemonics)");
|
||||
});
|
||||
|
||||
test("installs the config default and completion loader once", () => {
|
||||
const configFile = createConfigFile();
|
||||
|
||||
expect(installCompletions("bash", "xo-cli", configFile)).toBe(true);
|
||||
expect(installCompletions("bash", "xo-cli", configFile)).toBe(false);
|
||||
|
||||
const contents = readFileSync(configFile, "utf8");
|
||||
expect(contents.match(/XO_CONFIG_DIR/g)).toHaveLength(2);
|
||||
expect(
|
||||
contents.match(/eval "\$\(xo-cli completions bash\)"/g),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("adds a missing default without duplicating an existing loader", () => {
|
||||
const configFile = createConfigFile('eval "$(xo-cli completions bash)"\n');
|
||||
|
||||
expect(installCompletions("bash", "xo-cli", configFile)).toBe(true);
|
||||
|
||||
const contents = readFileSync(configFile, "utf8");
|
||||
expect(
|
||||
contents.match(/eval "\$\(xo-cli completions bash\)"/g),
|
||||
).toHaveLength(1);
|
||||
expect(contents).toContain(
|
||||
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves an existing custom config directory assignment", () => {
|
||||
const configFile = createConfigFile(
|
||||
"export XO_CONFIG_DIR=/tmp/custom-xo\n",
|
||||
);
|
||||
|
||||
expect(installCompletions("zsh", "xo-cli", configFile)).toBe(true);
|
||||
|
||||
const contents = readFileSync(configFile, "utf8");
|
||||
expect(contents).toContain("export XO_CONFIG_DIR=/tmp/custom-xo");
|
||||
expect(contents).not.toContain("${XO_CONFIG_DIR:-$HOME/.config/xo-cli}");
|
||||
expect(contents).toContain('eval "$(xo-cli completions zsh)"');
|
||||
});
|
||||
|
||||
test("uses fish syntax when installing fish completions", () => {
|
||||
const configFile = createConfigFile();
|
||||
|
||||
expect(installCompletions("fish", "xo-cli", configFile)).toBe(true);
|
||||
|
||||
const contents = readFileSync(configFile, "utf8");
|
||||
expect(contents).toContain(
|
||||
'set -q XO_CONFIG_DIR; or set -gx XO_CONFIG_DIR "$HOME/.config/xo-cli"',
|
||||
);
|
||||
expect(contents).toContain("xo-cli completions fish | source");
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,9 @@ describe("settings command", () => {
|
||||
{},
|
||||
);
|
||||
|
||||
const persisted = JSON.parse(readFileSync(paths.walletConfigPath, "utf8")) as {
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(paths.walletConfigPath, "utf8"),
|
||||
) as {
|
||||
currency: string;
|
||||
"default-mnemonic"?: string;
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ const testCases: TestCase[] = [
|
||||
inputs: ["export", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
logs: [{ out: "\"name\":\"Wallet (P2PKH)\"" }],
|
||||
logs: [{ out: '"name":"Wallet (P2PKH)"' }],
|
||||
},
|
||||
// Error cases - subcommand
|
||||
{
|
||||
|
||||
@@ -113,7 +113,9 @@ describe("mnemonic utilities", () => {
|
||||
|
||||
// Due to some weird MacOS behavior we need to use realpathSync to get the correct path
|
||||
// Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}`
|
||||
const expectedPath = realpathSync(path.join(tempDir, "mnemonic-relative"));
|
||||
const expectedPath = realpathSync(
|
||||
path.join(tempDir, "mnemonic-relative"),
|
||||
);
|
||||
|
||||
// Compare to the expected path
|
||||
expect(resolved).toBe(expectedPath);
|
||||
|
||||
@@ -159,7 +159,9 @@ export const createMockEngine = async (seed: string) => {
|
||||
};
|
||||
|
||||
export const createMockAppService = async (engine: Engine) => {
|
||||
const settings = new SettingsService(`${tmpdir()}/xo-cli-tests-settings.json`);
|
||||
const settings = new SettingsService(
|
||||
`${tmpdir()}/xo-cli-tests-settings.json`,
|
||||
);
|
||||
settings.setCurrency("USD");
|
||||
|
||||
const storage = await InMemoryStorage.create();
|
||||
|
||||
@@ -5,7 +5,10 @@ export class MockRatesService extends BaseRates {
|
||||
super();
|
||||
}
|
||||
|
||||
async getRate(numeratorUnitCode: string, denominatorUnitCode: string): Promise<number> {
|
||||
async getRate(
|
||||
numeratorUnitCode: string,
|
||||
denominatorUnitCode: string,
|
||||
): Promise<number> {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -20,4 +23,4 @@ export class MockRatesService extends BaseRates {
|
||||
async listPairs(): Promise<Set<string>> {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,11 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
realpathSync,
|
||||
} from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -12,6 +18,20 @@ import {
|
||||
} from "../../src/utils/paths";
|
||||
|
||||
describe("paths utilities", () => {
|
||||
const originalConfigDir = process.env["XO_CONFIG_DIR"];
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env["XO_CONFIG_DIR"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env["XO_CONFIG_DIR"];
|
||||
} else {
|
||||
process.env["XO_CONFIG_DIR"] = originalConfigDir;
|
||||
}
|
||||
});
|
||||
|
||||
describe("getConfigDir", () => {
|
||||
test("returns path under ~/.config/xo-cli", () => {
|
||||
const configDir = getConfigDir();
|
||||
@@ -24,6 +44,26 @@ describe("paths utilities", () => {
|
||||
|
||||
expect(existsSync(configDir)).toBe(true);
|
||||
});
|
||||
|
||||
test("uses XO_CONFIG_DIR when configured", () => {
|
||||
const customDir = path.join(tmpdir(), `xo-cli-config-test-${Date.now()}`);
|
||||
process.env["XO_CONFIG_DIR"] = customDir;
|
||||
|
||||
try {
|
||||
expect(getConfigDir()).toBe(customDir);
|
||||
expect(getMnemonicsDir()).toBe(path.join(customDir, "mnemonics"));
|
||||
expect(getDataDir()).toBe(path.join(customDir, "data"));
|
||||
expect(getWalletConfigPath()).toBe(path.join(customDir, ".wallet"));
|
||||
} finally {
|
||||
rmSync(customDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the default when XO_CONFIG_DIR is empty", () => {
|
||||
process.env["XO_CONFIG_DIR"] = "";
|
||||
|
||||
expect(getConfigDir()).toBe(path.join(homedir(), ".config", "xo-cli"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMnemonicsDir", () => {
|
||||
@@ -96,7 +136,9 @@ describe("paths utilities", () => {
|
||||
|
||||
// Due to some weird MacOS behavior we need to use realpathSync to get the correct path
|
||||
// Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}`
|
||||
const expectedPath = realpathSync(path.join(tempDir, "mnemonic-cwd-test"));
|
||||
const expectedPath = realpathSync(
|
||||
path.join(tempDir, "mnemonic-cwd-test"),
|
||||
);
|
||||
|
||||
// Compare to the expected path
|
||||
expect(resolved).toBe(expectedPath);
|
||||
@@ -106,6 +148,7 @@ describe("paths utilities", () => {
|
||||
});
|
||||
|
||||
test("resolves from global mnemonics dir when file exists there", () => {
|
||||
process.env["XO_CONFIG_DIR"] = tempDir;
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
const testFile = path.join(mnemonicsDir, "mnemonic-global-test");
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("formatDialogMessageLines", () => {
|
||||
|
||||
test("breaks long dot-separated paths at segment boundaries", () => {
|
||||
const line =
|
||||
"- actions.requestFungibleTokens.roles.receiver.requirements: Unrecognized key: \"generate\"";
|
||||
'- actions.requestFungibleTokens.roles.receiver.requirements: Unrecognized key: "generate"';
|
||||
const lines = formatDialogMessageLines(line, 56);
|
||||
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
|
||||
Reference in New Issue
Block a user