Compare commits

...

5 Commits

14 changed files with 1477 additions and 1568 deletions

165
package-lock.json generated
View File

@@ -12,11 +12,11 @@
"@bitauth/libauth": "^3.0.0", "@bitauth/libauth": "^3.0.0",
"@electrum-cash/protocol": "^2.3.1", "@electrum-cash/protocol": "^2.3.1",
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152", "@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@xo-cash/crypto": "file:../crypto", "@xo-cash/crypto": "^0.0.1",
"@xo-cash/engine": "file:../engine", "@xo-cash/engine": "file:../engine",
"@xo-cash/state": "file:../state", "@xo-cash/state": "file:../state",
"@xo-cash/templates": "file:../templates", "@xo-cash/templates": "^0.0.1",
"@xo-cash/types": "file:../types", "@xo-cash/types": "^0.0.1",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0", "clipboardy": "^5.1.0",
"ink": "^6.6.0", "ink": "^6.6.0",
@@ -41,38 +41,6 @@
"vitest": "^4.1.2" "vitest": "^4.1.2"
} }
}, },
"../crypto": {
"name": "@xo-cash/crypto",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@xo-cash/primitives": "0.0.1",
"@xo-cash/types": "0.0.1"
},
"devDependencies": {
"@chalp/eslint-airbnb": "^1.3.0",
"@generalprotocols/cspell-dictionary": "^1.0.1",
"@stylistic/eslint-plugin": "^5.7.0",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitest/coverage-v8": "^4.0.17",
"@viz-kit/esbuild-analyzer": "^1.0.0",
"@xo-cash/eslint-config": "1.0.1",
"cspell": "^9.6.0",
"eslint": "^9.39.2",
"prettier": "^3.6.2",
"tsdown": "^0.20.0-beta.4",
"typedoc": "^0.28.16",
"typedoc-plugin-coverage": "^4.0.2",
"typescript": "^5.3.2",
"typescript-eslint": "^8.53.1",
"vitest": "^4.0.17"
},
"engines": {
"node": ">=18.0.0"
}
},
"../engine": { "../engine": {
"name": "@xo-cash/engine", "name": "@xo-cash/engine",
"version": "0.0.1", "version": "0.0.1",
@@ -85,7 +53,8 @@
"@electrum-cash/servers": "^3.1.0", "@electrum-cash/servers": "^3.1.0",
"@xo-cash/crypto": "0.0.1", "@xo-cash/crypto": "0.0.1",
"@xo-cash/primitives": "0.0.1", "@xo-cash/primitives": "0.0.1",
"@xo-cash/state": "0.0.1", "@xo-cash/state": "0.0.2",
"@xo-cash/templates": "0.0.1",
"@xo-cash/types": "0.0.1", "@xo-cash/types": "0.0.1",
"@xo-cash/utils": "0.0.1", "@xo-cash/utils": "0.0.1",
"eventemitter3": "^5.0.1" "eventemitter3": "^5.0.1"
@@ -108,14 +77,11 @@
"typescript": "^5.3.2", "typescript": "^5.3.2",
"typescript-eslint": "^8.53.1", "typescript-eslint": "^8.53.1",
"vitest": "^4.0.17" "vitest": "^4.0.17"
},
"engines": {
"node": ">=18.0.0"
} }
}, },
"../state": { "../state": {
"name": "@xo-cash/state", "name": "@xo-cash/state",
"version": "0.0.1", "version": "0.0.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bitauth/libauth": "^3.1.0-next.8", "@bitauth/libauth": "^3.1.0-next.8",
@@ -147,60 +113,6 @@
"vitest": "^4.0.17" "vitest": "^4.0.17"
} }
}, },
"../templates": {
"name": "@xo-cash/templates",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@xo-cash/types": "0.0.1-development.13504604083"
},
"devDependencies": {
"@chalp/eslint-airbnb": "^1.3.0",
"@generalprotocols/cspell-dictionary": "^1.0.1",
"@stylistic/eslint-plugin": "^5.7.0",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitest/coverage-v8": "^4.0.17",
"@viz-kit/esbuild-analyzer": "^1.0.0",
"@xo-cash/eslint-config": "1.0.1",
"cspell": "^9.6.0",
"eslint": "^9.39.2",
"prettier": "^3.6.2",
"tsdown": "^0.20.0-beta.4",
"typedoc": "^0.28.16",
"typedoc-plugin-coverage": "^4.0.2",
"typescript": "^5.3.2",
"typescript-eslint": "^8.53.1",
"vitest": "^4.0.17"
}
},
"../types": {
"name": "@xo-cash/types",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8"
},
"devDependencies": {
"@chalp/eslint-airbnb": "^1.3.0",
"@generalprotocols/cspell-dictionary": "^1.0.1",
"@stylistic/eslint-plugin": "^5.7.0",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitest/coverage-v8": "^4.0.17",
"@viz-kit/esbuild-analyzer": "^1.0.0",
"@xo-cash/eslint-config": "1.0.1",
"cspell": "^9.6.0",
"eslint": "^9.39.2",
"prettier": "^3.6.2",
"tsdown": "^0.20.0-beta.4",
"typedoc": "^0.28.16",
"typedoc-plugin-coverage": "^4.0.2",
"typescript": "^5.3.2",
"typescript-eslint": "^8.53.1",
"vitest": "^4.0.17"
}
},
"node_modules/@alcalzone/ansi-tokenize": { "node_modules/@alcalzone/ansi-tokenize": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz",
@@ -971,24 +883,77 @@
} }
}, },
"node_modules/@xo-cash/crypto": { "node_modules/@xo-cash/crypto": {
"resolved": "../crypto", "version": "0.0.1",
"link": true "resolved": "https://registry.npmjs.org/@xo-cash/crypto/-/crypto-0.0.1.tgz",
"integrity": "sha512-ZIa9MHAVCBJqo5uxyx/Tx/jTSyyJw1cfYfI48gEHqBIl5wyyxiZDx4eZvVWSr8uKgS5Tm3FXUkKQybvk5QGRIQ==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@xo-cash/primitives": "0.0.1",
"@xo-cash/types": "0.0.1"
}
},
"node_modules/@xo-cash/crypto/node_modules/@bitauth/libauth": {
"version": "3.1.0-next.8",
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
}, },
"node_modules/@xo-cash/engine": { "node_modules/@xo-cash/engine": {
"resolved": "../engine", "resolved": "../engine",
"link": true "link": true
}, },
"node_modules/@xo-cash/primitives": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@xo-cash/primitives/-/primitives-0.0.1.tgz",
"integrity": "sha512-medxVK9Sawj7oIDhWvTjTgzwf6BjGao6CXtQYJOUFi6NOO1eclb1PDjEmkG/4NeK3v7LQIN8QS60mTAGyS9FXg==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8"
}
},
"node_modules/@xo-cash/primitives/node_modules/@bitauth/libauth": {
"version": "3.1.0-next.8",
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@xo-cash/state": { "node_modules/@xo-cash/state": {
"resolved": "../state", "resolved": "../state",
"link": true "link": true
}, },
"node_modules/@xo-cash/templates": { "node_modules/@xo-cash/templates": {
"resolved": "../templates", "version": "0.0.1",
"link": true "resolved": "https://registry.npmjs.org/@xo-cash/templates/-/templates-0.0.1.tgz",
"integrity": "sha512-v5f0YeH9Bw6lNThdE0fI878T4L2jbM8RI1quxdKxnvqHn9hu2jzebqvveEB2TfJWG3sP1GpE1go0Yn87R4sXfw==",
"license": "MIT",
"dependencies": {
"@xo-cash/types": "0.0.1"
}
}, },
"node_modules/@xo-cash/types": { "node_modules/@xo-cash/types": {
"resolved": "../types", "version": "0.0.1",
"link": true "resolved": "https://registry.npmjs.org/@xo-cash/types/-/types-0.0.1.tgz",
"integrity": "sha512-BMwh2Y9+LqnTXYmdA7Nxi1NuK+AcsNWFoFGJVAvuY5TBfsbNIzWppjmrI2fAyj/RlSE3tATMxam+6CJb3RnDIA==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8"
}
},
"node_modules/@xo-cash/types/node_modules/@bitauth/libauth": {
"version": "3.1.0-next.8",
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
}, },
"node_modules/ansi-escapes": { "node_modules/ansi-escapes": {
"version": "7.2.0", "version": "7.2.0",

View File

@@ -19,7 +19,11 @@
"nuke": "tsx scripts/rm-dbs.ts", "nuke": "tsx scripts/rm-dbs.ts",
"nuke:dry": "tsx scripts/rm-dbs.ts --dry", "nuke:dry": "tsx scripts/rm-dbs.ts --dry",
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore", "format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
"format:check": "prettier --check ." "format:check": "prettier --check .",
"autocomplete:install": "node dist/cli/index.js completions bash --install",
"autocomplete:install:bash": "node dist/cli/index.js completions bash --install",
"autocomplete:install:zsh": "node dist/cli/index.js completions zsh --install",
"autocomplete:install:fish": "node dist/cli/index.js completions fish --install"
}, },
"keywords": [ "keywords": [
"crypto", "crypto",
@@ -34,11 +38,11 @@
"@bitauth/libauth": "^3.0.0", "@bitauth/libauth": "^3.0.0",
"@electrum-cash/protocol": "^2.3.1", "@electrum-cash/protocol": "^2.3.1",
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152", "@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@xo-cash/crypto": "file:../crypto", "@xo-cash/crypto": "^0.0.1",
"@xo-cash/engine": "file:../engine", "@xo-cash/engine": "file:../engine",
"@xo-cash/state": "file:../state", "@xo-cash/state": "file:../state",
"@xo-cash/templates": "file:../templates", "@xo-cash/templates": "^0.0.1",
"@xo-cash/types": "file:../types", "@xo-cash/types": "^0.0.1",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0", "clipboardy": "^5.1.0",
"ink": "^6.6.0", "ink": "^6.6.0",

View File

@@ -5,23 +5,48 @@
### Full Installation ### Full Installation
```bash ```bash
# Create a new directory since we are going to be pulling in engine too # Create a new directory since we are going to be pulling in engine too
mdkir xo-terminal && cd xo-terminal mkdir xo-terminal && cd xo-terminal
# Clone the Engine Repo # ----- Start Engine Setup -----
git clone git@gitlab.com:GeneralProtocols/xo/engine.git # Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
git clone git@gitlab.com:Harvmaster/engine.git
# Move into teh engine directory # Move into teh engine directory
cd engine cd engine
# Checkout the cli-test branch
git checkout cli-test
# Install the dependencies # Install the dependencies
npm ci npm ci
# Build the engine # Build the engine
npm run build npm run build
# ----- End Engine Setup -----
# Move back to the top level directory # Move back to the top level directory
cd .. cd ..
# ----- Start State Setup -----
# Clone the State Repo
git clone git@gitlab.com:Harvmaster/state.git
# Move into the state directory
cd state
git checkout in-memory-adapter
# Install the dependencies
npm ci
# Build the state
npm run build
# ----- End State Setup -----
# Move back to the top level directory
cd ..
# ----- Start CLI Setup -----
# Clone the CLI Repo # Clone the CLI Repo
git clone git@git.harvmaster.com:Harvmaster/xo-cli.git git clone git@git.harvmaster.com:Harvmaster/xo-cli.git
@@ -33,6 +58,13 @@ npm ci
# Build the cli # Build the cli
npm run build npm run build
# ----- End CLI Setup -----
```
### Run TUI in dev mode
```bash
npm run dev
``` ```
### Install globally ### Install globally
@@ -42,6 +74,23 @@ npm run build
npm install -g . npm install -g .
``` ```
### Install autocomplete completions (From the xo-cli 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 ### Run the CLI
```bash ```bash
# If globally installed (Not really usable if not globally installed) # If globally installed (Not really usable if not globally installed)

View File

@@ -19,6 +19,7 @@ import { EventEmitter } from "../utils/event-emitter.js";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { p2pkhTemplate } from "@xo-cash/templates"; import { p2pkhTemplate } from "@xo-cash/templates";
import { hexToBin } from "@bitauth/libauth"; import { hexToBin } from "@bitauth/libauth";
import { parseTemplate } from "@xo-cash/engine";
export type AppEventMap = { export type AppEventMap = {
"invitation-added": Invitation; "invitation-added": Invitation;
@@ -95,7 +96,7 @@ export class AppService extends EventEmitter<AppEventMap> {
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs? // TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
await engine.setDefaultLockingParameters( await engine.setDefaultLockingParameters(
generateTemplateIdentifier(p2pkhTemplate), generateTemplateIdentifier(parseTemplate(p2pkhTemplate)),
"receiveOutput", "receiveOutput",
"receiver", "receiver",
); );

View File

@@ -1,5 +1,9 @@
import { binToHex } from "@bitauth/libauth"; import { binToHex } from "@bitauth/libauth";
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; import {
compileCashAssemblyString,
type Engine,
listInvitationCommitsByEntity,
} from "@xo-cash/engine";
import type { UnspentOutputData } from "@xo-cash/state"; import type { UnspentOutputData } from "@xo-cash/state";
import type { import type {
XOInvitation, XOInvitation,
@@ -59,6 +63,7 @@ interface InvitationContext {
invitation: Invitation; invitation: Invitation;
template: XOTemplate | null; template: XOTemplate | null;
variables: Record<string, XOInvitationVariableValue>; variables: Record<string, XOInvitationVariableValue>;
walletCommits: XOInvitationCommit[];
walletEntityIdentifier?: string; walletEntityIdentifier?: string;
} }
@@ -73,12 +78,8 @@ export class HistoryService {
private invitations: Invitation[], private invitations: Invitation[],
) {} ) {}
async extractEntities(invitation: XOInvitation): Promise<string[]> { extractEntities(invitation: XOInvitation): Record<string, XOInvitationCommit[]> {
const entities = new Set<string>(); return listInvitationCommitsByEntity(invitation);
for (const commit of invitation.commits) {
entities.add(commit.entityIdentifier);
}
return Array.from(entities);
} }
// Entities are currently static per invitation. So, we can try to match the roles to entities by: // Entities are currently static per invitation. So, we can try to match the roles to entities by:
@@ -127,8 +128,6 @@ export class HistoryService {
async getHistory(): Promise<HistoryItem[]> { async getHistory(): Promise<HistoryItem[]> {
const allUtxos = await this.engine.listUnspentOutputsData(); const allUtxos = await this.engine.listUnspentOutputsData();
const ownOutpoints = new Set<string>();
const ownLockingBytecodes = new Set<string>();
const invitationByOrigin = new Map<string, UtxoOriginContext>(); const invitationByOrigin = new Map<string, UtxoOriginContext>();
const outpointValueSatoshis = new Map<string, bigint>(); const outpointValueSatoshis = new Map<string, bigint>();
@@ -137,8 +136,6 @@ export class HistoryService {
utxo.outpointTransactionHash, utxo.outpointTransactionHash,
utxo.outpointIndex, utxo.outpointIndex,
); );
ownOutpoints.add(outpointKey);
ownLockingBytecodes.add(utxo.lockingBytecode);
outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis)); outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis));
} }
@@ -148,15 +145,15 @@ export class HistoryService {
const template = const template =
(await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? (await this.engine.getTemplate(invitation.data.templateIdentifier)) ??
null; null;
const walletEntityIdentifier = this.resolveWalletEntityIdentifier( const walletCommits = await this.getWalletCommitsForInvitation(
invitation, invitation.data,
ownOutpoints,
ownLockingBytecodes,
); );
const walletEntityIdentifier = walletCommits[0]?.entityIdentifier;
contexts.set(invitation.data.invitationIdentifier, { contexts.set(invitation.data.invitationIdentifier, {
invitation, invitation,
template, template,
variables, variables,
walletCommits,
walletEntityIdentifier, walletEntityIdentifier,
}); });
this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation); this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation);
@@ -186,7 +183,6 @@ export class HistoryService {
const invitationInputs = this.buildWalletInputItemsForInvitation( const invitationInputs = this.buildWalletInputItemsForInvitation(
context, context,
roles[0], roles[0],
invitationOutputs.length > 0,
outpointValueSatoshis, outpointValueSatoshis,
); );
const invitationDescription = this.deriveInvitationDescription( const invitationDescription = this.deriveInvitationDescription(
@@ -287,51 +283,25 @@ export class HistoryService {
return outputs; return outputs;
} }
private async getWalletCommitsForInvitation(
invitation: XOInvitation,
): Promise<XOInvitationCommit[]> {
try {
return await this.engine.getOwnCommits(invitation);
} catch {
return [];
}
}
private buildWalletInputItemsForInvitation( private buildWalletInputItemsForInvitation(
context: InvitationContext, context: InvitationContext,
walletRole?: string, walletRole?: string,
hasWalletOutputs: boolean = false,
outpointValueSatoshis: Map<string, bigint> = new Map(), outpointValueSatoshis: Map<string, bigint> = new Map(),
): HistoryUtxoItem[] { ): HistoryUtxoItem[] {
const invitation = context.invitation.data; const invitation = context.invitation.data;
const commits = invitation.commits ?? []; const relevantCommits = context.walletCommits.filter(
const commitsByEntity = context.walletEntityIdentifier
? commits.filter(
(commit) =>
commit.entityIdentifier === context.walletEntityIdentifier,
)
: [];
const commitsByRole = walletRole
? commits.filter(
(commit) =>
this.deriveCommitRoleIdentifier(
commit,
invitation,
context.template,
) === walletRole,
)
: [];
let relevantCommits = commitsByEntity.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0, (commit) => (commit.data.inputs?.length ?? 0) > 0,
); );
if (relevantCommits.length === 0) {
relevantCommits = commitsByRole.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0,
);
}
if (relevantCommits.length === 0 && walletRole === "sender") {
relevantCommits = commits.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0,
);
}
// Sender fallback only when no wallet outputs were matched.
if (relevantCommits.length === 0 && !hasWalletOutputs) {
relevantCommits = commits.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0,
);
}
const txDescription = this.deriveTransactionActivityDescription( const txDescription = this.deriveTransactionActivityDescription(
invitation, invitation,
context.template, context.template,
@@ -355,7 +325,10 @@ export class HistoryService {
context.variables, context.variables,
); );
const templateName = context.template?.name ?? "UnknownTemplate"; const templateName = context.template?.name ?? "UnknownTemplate";
const role = walletRole ?? "sender"; const role =
this.deriveCommitRoleIdentifier(commit, invitation, context.template) ??
walletRole ??
"sender";
const inputValue = this.resolveInputSatoshis( const inputValue = this.resolveInputSatoshis(
txHash, txHash,
inputIndex, inputIndex,
@@ -422,13 +395,6 @@ export class HistoryService {
}; };
} }
/**
* TODO: This is completely incorrect. It should be deriving the roles of the user based on the entity IDs in the commits, then mapping each commit to Inputs/Outputs so we know what belongs to the user.
* There are a few changes that will need to be made to make this work:
* 1. Provide a way to derive all entity IDs of the user for the invitation (If we are going with XPub)
* 2. Provide a way to get only the User's commits (and their inputs/outputs)
* 3. (Maybe) Include role on inputs and outputs - This one might be fine with just using the commit entity id
*/
private deriveWalletRolesForInvitation( private deriveWalletRolesForInvitation(
context: InvitationContext, context: InvitationContext,
outputs: HistoryUtxoItem[], outputs: HistoryUtxoItem[],
@@ -444,33 +410,20 @@ export class HistoryService {
roles.add("receiver"); roles.add("receiver");
} }
const hasInputCommit = ( for (const commit of context.walletCommits) {
context.walletEntityIdentifier const role = this.deriveCommitRoleIdentifier(
? context.invitation.data.commits.filter( commit,
(c) => c.entityIdentifier === context.walletEntityIdentifier,
)
: context.invitation.data.commits
).some((c) => (c.data.inputs?.length ?? 0) > 0);
if (hasInputCommit) roles.add("sender");
if (
!hasInputCommit &&
outputs.length === 0 &&
context.invitation.data.commits.some(
(c) => (c.data.inputs?.length ?? 0) > 0,
)
) {
roles.add("sender");
}
if (roles.size === 0) {
const inferred = this.extractInvitationRoleIdentifier(
context.invitation.data, context.invitation.data,
context.template, context.template,
context.walletEntityIdentifier,
); );
if (inferred) roles.add(inferred); if (role) roles.add(role);
} }
const hasInputCommit = context.walletCommits.some(
(c) => (c.data.inputs?.length ?? 0) > 0,
);
if (hasInputCommit) roles.add("sender");
return roles.size > 0 ? Array.from(roles) : ["unknown"]; return roles.size > 0 ? Array.from(roles) : ["unknown"];
} }
@@ -521,7 +474,7 @@ export class HistoryService {
const originKey = this.getUtxoOriginKey( const originKey = this.getUtxoOriginKey(
utxo.templateIdentifier, utxo.templateIdentifier,
utxo.outputIdentifier, utxo.outputIdentifier,
utxo.lockingBytecode, utxo.scriptHash,
); );
return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier; return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier;
} }
@@ -533,59 +486,11 @@ export class HistoryService {
const originKey = this.getUtxoOriginKey( const originKey = this.getUtxoOriginKey(
utxo.templateIdentifier, utxo.templateIdentifier,
utxo.outputIdentifier, utxo.outputIdentifier,
utxo.lockingBytecode, utxo.scriptHash,
); );
return invitationByUtxoOrigin.get(originKey)?.roleIdentifier; return invitationByUtxoOrigin.get(originKey)?.roleIdentifier;
} }
private resolveWalletEntityIdentifier(
invitation: Invitation,
ownUtxoOutpointKeys: Set<string>,
ownLockingBytecodes: Set<string>,
): string | undefined {
const scores = new Map<string, number>();
const addScore = (entityIdentifier: string, delta: number): void => {
scores.set(entityIdentifier, (scores.get(entityIdentifier) ?? 0) + delta);
};
for (const commit of invitation.data.commits) {
for (const input of commit.data.inputs ?? []) {
const txHash = input.outpointTransactionHash
? input.outpointTransactionHash instanceof Uint8Array
? binToHex(input.outpointTransactionHash)
: String(input.outpointTransactionHash)
: undefined;
if (!txHash || input.outpointIndex === undefined) continue;
if (
ownUtxoOutpointKeys.has(
this.getOutpointKey(txHash, input.outpointIndex),
)
) {
addScore(commit.entityIdentifier, 3);
}
}
for (const output of commit.data.outputs ?? []) {
const lockingBytecodeHex = output.lockingBytecode
? this.toLockingBytecodeHex(output.lockingBytecode)
: undefined;
if (!lockingBytecodeHex) continue;
if (ownLockingBytecodes.has(lockingBytecodeHex)) {
addScore(commit.entityIdentifier, 2);
}
}
}
let bestEntity: string | undefined;
let bestScore = 0;
for (const [entity, score] of scores.entries()) {
if (score > bestScore) {
bestScore = score;
bestEntity = entity;
}
}
return bestEntity;
}
private deriveUtxoDescription( private deriveUtxoDescription(
utxo: UnspentOutputData, utxo: UnspentOutputData,
template: XOTemplate | null, template: XOTemplate | null,
@@ -715,27 +620,6 @@ export class HistoryService {
return undefined; return undefined;
} }
private extractInvitationRoleIdentifier(
invitation: XOInvitation,
template: XOTemplate | null,
walletEntityIdentifier?: string,
): string | undefined {
if (walletEntityIdentifier) {
const commits = invitation.commits.filter(
(commit) => commit.entityIdentifier === walletEntityIdentifier,
);
for (const commit of commits) {
const role = this.deriveCommitRoleIdentifier(
commit,
invitation,
template,
);
if (role) return role;
}
}
return undefined;
}
private inferRoleFromOutputIdentifier( private inferRoleFromOutputIdentifier(
outputIdentifier: string, outputIdentifier: string,
): string | undefined { ): string | undefined {

View File

@@ -2,7 +2,7 @@ import type {
AcceptInvitationParameters, AcceptInvitationParameters,
AppendInvitationParameters, AppendInvitationParameters,
Engine, Engine,
FindSuitableResourcesParameters, GetSpendableResourcesParameters,
} from "@xo-cash/engine"; } from "@xo-cash/engine";
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine"; import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
import type { import type {
@@ -85,8 +85,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
throw new Error(`Template not found: ${invitation.templateIdentifier}`); throw new Error(`Template not found: ${invitation.templateIdentifier}`);
} }
// engine invitation (I have no idea if this is required)
const engineInvitation = await dependencies.engine.acceptInvitation(invitation);
// Create the invitation // Create the invitation
const invitationInstance = new Invitation(invitation, dependencies); const invitationInstance = new Invitation(engineInvitation, dependencies);
// Start the invitation and its tracking // Start the invitation and its tracking
await invitationInstance.start(); await invitationInstance.start();
@@ -483,12 +486,27 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
} }
async findSuitableResources( async findSuitableResources(
options: Partial<FindSuitableResourcesParameters> = {}, options: Partial<GetSpendableResourcesParameters> = {},
): Promise<UnspentOutputData[]> { ): Promise<UnspentOutputData[]> {
const templateIdentifier =
options.templateIdentifier ?? this.data.templateIdentifier;
const template = await this.engine.getTemplate(templateIdentifier);
const fallbackOutputIdentifier = Object.keys(template?.outputs ?? {})[0];
if (!fallbackOutputIdentifier && !options.outputIdentifier) {
throw new Error(
`No output identifiers found for template: ${templateIdentifier}`,
);
}
const resolvedOptions: GetSpendableResourcesParameters = {
templateIdentifier,
outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
};
// Find the suitable resources // Find the suitable resources
const { unspentOutputs } = await this.engine.findSuitableResources( const { unspentOutputs } = await this.engine.getSpendableResources(
this.data, this.data,
options, resolvedOptions,
); );
// Update the status of the invitation // Update the status of the invitation

View File

@@ -100,8 +100,8 @@ export function TemplateListScreen(): React.ReactElement {
for (const startingAction of rawStartingActions) { for (const startingAction of rawStartingActions) {
const existing = actionMap.get(startingAction.action); const existing = actionMap.get(startingAction.action);
if (existing) { if (existing) {
if (!existing.roles.includes(startingAction.role)) { if (!existing.roles.includes(startingAction.role ?? '')) {
existing.roles.push(startingAction.role); existing.roles.push(startingAction.role ?? '');
} }
continue; continue;
} }
@@ -111,7 +111,7 @@ export function TemplateListScreen(): React.ReactElement {
actionIdentifier: startingAction.action, actionIdentifier: startingAction.action,
name: actionDef?.name || startingAction.action, name: actionDef?.name || startingAction.action,
description: actionDef?.description, description: actionDef?.description,
roles: [startingAction.role], roles: [startingAction.role ?? ''],
source: 'starting', source: 'starting',
}); });
} }
@@ -119,9 +119,9 @@ export function TemplateListScreen(): React.ReactElement {
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>(); const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
for (const outputIdentifier of ownedOutputIdentifiers) { for (const outputIdentifier of ownedOutputIdentifiers) {
const outputDef = template.outputs?.[outputIdentifier]; const outputDef = template.outputs?.[outputIdentifier];
if (!outputDef || typeof outputDef.lockscript !== 'string') continue; if (!outputDef || typeof outputDef.lockingScript !== 'string') continue;
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockingScript] as
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> } | { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
| undefined; | undefined;
if (!lockingScriptDefinition?.roles) continue; if (!lockingScriptDefinition?.roles) continue;

View File

@@ -15,7 +15,7 @@ export { DataWizardFlow } from "./DataWizardFlow.js";
*/ */
export function createWizardFlow(action: XOTemplateAction): WizardFlow { export function createWizardFlow(action: XOTemplateAction): WizardFlow {
if (action.data?.length && !action.transaction) { if (action.data?.length && !action.transaction) {
return new DataWizardFlow(action.data); return new DataWizardFlow([action.data]);
} }
return new TransactionWizardFlow(); return new TransactionWizardFlow();
} }

View File

@@ -61,13 +61,16 @@ export function RoleSelectStep({
{availableRoles.length === 0 ? ( {availableRoles.length === 0 ? (
<Text color={colors.textMuted}>No roles available</Text> <Text color={colors.textMuted}>No roles available</Text>
) : ( ) : (
availableRoles.map((roleId, index) => { availableRoles.map((roleId: string, index: number) => {
const isCursor = const isCursor =
selectedRoleIndex === index && focusArea === 'content'; selectedRoleIndex === index && focusArea === 'content';
const roleDef = template.roles?.[roleId]; const roleDef = template.roles?.[roleId];
const actionRole = action?.roles?.[roleId]; const actionRole = action?.roles?.[roleId];
const requirements = actionRole?.requirements; const requirements = actionRole?.requirements;
const actionRequirements = action?.requirements;
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleId);
return ( return (
<Box key={roleId} flexDirection="column" marginY={0}> <Box key={roleId} flexDirection="column" marginY={0}>
<Text <Text
@@ -96,10 +99,10 @@ export function RoleSelectStep({
{' '} {' '}
</Text> </Text>
)} )}
{requirements.slots && requirements.slots.min > 0 && ( {actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 && (
<Text color={colors.textMuted} dimColor> <Text color={colors.textMuted} dimColor>
{requirements.slots.min} input slot {actionRoleRequirements.slots.min} input slot
{requirements.slots.min !== 1 ? 's' : ''} {actionRoleRequirements.slots.min !== 1 ? 's' : ''}
</Text> </Text>
)} )}
</Box> </Box>

View File

@@ -351,10 +351,10 @@ export function InvitationScreen(): React.ReactElement {
const seenLockingBytecodes = new Set<string>(); const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) { for (const utxo of utxos) {
const lockingBytecodeHex = utxo.lockingBytecode const lockingBytecodeHex = utxo.scriptHash
? typeof utxo.lockingBytecode === 'string' ? typeof utxo.scriptHash === 'string'
? utxo.lockingBytecode ? utxo.scriptHash
: Buffer.from(utxo.lockingBytecode).toString('hex') : Buffer.from(utxo.scriptHash).toString('hex')
: undefined; : undefined;
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue; if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;

View File

@@ -45,7 +45,8 @@ export const resolveActionRoles = (
const starts = template.start ?? []; const starts = template.start ?? [];
const roleIds = starts const roleIds = starts
.filter((entry) => entry.action === actionIdentifier) .filter((entry) => entry.action === actionIdentifier)
.map((entry) => entry.role); .map((entry) => entry.role)
.filter((roleId) => roleId !== undefined);
return [...new Set(roleIds)]; return [...new Set(roleIds)];
}; };
@@ -60,17 +61,11 @@ export const roleRequiresInputs = (
if (!action) return false; if (!action) return false;
const actionRole = action.roles?.[roleIdentifier]; const actionRole = action.roles?.[roleIdentifier];
const roleSlotsMin = actionRole?.requirements?.slots?.min ?? 0; const actionRequirements = action.requirements;
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleIdentifier);
const roleSlotsMin = actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 ? actionRoleRequirements.slots.min : 0;
if (roleSlotsMin > 0) return true; if (roleSlotsMin > 0) return true;
// Some templates specify slot/input requirements at action.requirements.roles
// instead of role.requirements. Respect those as well.
const roleRequirement = action.requirements?.roles?.find(
(requirement) => requirement.role === roleIdentifier,
);
const actionLevelSlotsMin = roleRequirement?.slots?.min ?? 0;
if (actionLevelSlotsMin > 0) return true;
const transactionIdentifier = action.transaction; const transactionIdentifier = action.transaction;
const transaction = transactionIdentifier const transaction = transactionIdentifier
? template.transactions?.[transactionIdentifier] ? template.transactions?.[transactionIdentifier]
@@ -132,13 +127,12 @@ export const resolveProvidedLockingBytecodeHex = (
variableValues: Record<string, string>, variableValues: Record<string, string>,
): string | undefined => { ): string | undefined => {
const outputDefinition = template.outputs?.[outputIdentifier]; const outputDefinition = template.outputs?.[outputIdentifier];
if (!outputDefinition || typeof outputDefinition.lockscript !== "string") if (!outputDefinition || typeof outputDefinition.lockingScript !== "string") {
return undefined; return undefined;
}
const lockingScriptDefinition = ( const lockingScriptDefinition = template.lockingScripts?.[outputDefinition.lockingScript];
template.lockingScripts as Record<string, unknown> | undefined const scriptIdentifier = lockingScriptDefinition?.lockingBytecode;
)?.[outputDefinition.lockscript] as { lockingScript?: string } | undefined;
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
if (!scriptIdentifier) return undefined; if (!scriptIdentifier) return undefined;
const scriptExpression = ( const scriptExpression = (

View File

@@ -188,11 +188,13 @@ export function getRolesForAction(
); );
return startEntries.map((entry) => { return startEntries.map((entry) => {
const roleDef = template.roles?.[entry.role]; const roleDef = template.roles?.[entry.role || ''];
const roleObj = typeof roleDef === "object" ? roleDef : null; const roleObj = typeof roleDef === "object" ? roleDef : null;
// TODO: This is ugly. Lot of conditionals. Need to take a much closer look at this.
return { return {
roleId: entry.role, roleId: entry.role || '',
name: roleObj?.name || entry.role, name: roleObj?.name || entry.role || '',
description: roleObj?.description, description: roleObj?.description,
}; };
}); });

View File

@@ -73,7 +73,7 @@ export const addFakeResource = async (
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(), outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
minedAtHeight: options.minedAtHeight ?? 800000, minedAtHeight: options.minedAtHeight ?? 800000,
valueSatoshis: options.valueSatoshis ?? 10000, valueSatoshis: options.valueSatoshis ?? 10000,
lockingBytecode: scriptHash:
options.lockingBytecode ?? options.lockingBytecode ??
"76a914000000000000000000000000000000000000000088ac", "76a914000000000000000000000000000000000000000088ac",
reservedBy: options.reservedBy, reservedBy: options.reservedBy,
@@ -131,7 +131,7 @@ export const unreserveResource = async (
export const createMockEngine = async (seed: string) => { export const createMockEngine = async (seed: string) => {
// Create the in-memory storage adapter. // Create the in-memory storage adapter.
const storage = await createStorageAdapter({ const storage = await createStorageAdapter({
storageType: StorageType.INMEMORY, storageType: "inmemory",
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))), accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
}); });

File diff suppressed because it is too large Load Diff