Breaking Change: Update to latest XO-Engine #2

Open
Harvmaster wants to merge 22 commits from kiok-update into main
25 changed files with 825 additions and 152 deletions
Showing only changes of commit 85746c3306 - Show all commits

48
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@xo-cash/state": "file:../state", "@xo-cash/state": "file:../state",
"@xo-cash/templates": "file:../templates", "@xo-cash/templates": "file:../templates",
"@xo-cash/types": "^0.0.1", "@xo-cash/types": "^0.0.1",
"@xo-cash/utils": "file:../utils",
"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",
@@ -47,16 +48,16 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bitauth/libauth": "^3.1.0-next.8", "@bitauth/libauth": "^3.1.0-next.8",
"@electrum-cash/application": "^0.2.3-development.13424909069", "@electrum-cash/application": "^0.2.3-development.13447192992",
"@electrum-cash/network": "^4.2.2", "@electrum-cash/network": "^4.2.2",
"@electrum-cash/protocol": "^2.3.1", "@electrum-cash/protocol": "^2.3.1",
"@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": "file:../primitives",
"@xo-cash/state": "0.0.2", "@xo-cash/state": "file:../state",
"@xo-cash/templates": "0.0.1", "@xo-cash/templates": "0.0.1",
"@xo-cash/types": "0.0.1", "@xo-cash/types": "^0.0.1-development.14519184304",
"@xo-cash/utils": "0.0.1", "@xo-cash/utils": "^0.0.1-development.14519184505",
"eventemitter3": "^5.0.1" "eventemitter3": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -140,6 +141,37 @@
"vitest": "^4.0.17" "vitest": "^4.0.17"
} }
}, },
"../utils": {
"name": "@xo-cash/utils",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@xo-cash/types": "0.0.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@chalp/eslint-airbnb": "^1.3.0",
"@generalprotocols/cspell-dictionary": "^1.0.1",
"@stylistic/eslint-plugin": "^5.7.0",
"@types/node": "^25.5.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",
"@xo-cash/templates": "0.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",
@@ -977,6 +1009,10 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
} }
}, },
"node_modules/@xo-cash/utils": {
"resolved": "../utils",
"link": true
},
"node_modules/ansi-escapes": { "node_modules/ansi-escapes": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",

View File

@@ -41,6 +41,7 @@
"@xo-cash/state": "file:../state", "@xo-cash/state": "file:../state",
"@xo-cash/templates": "file:../templates", "@xo-cash/templates": "file:../templates",
"@xo-cash/types": "^0.0.1", "@xo-cash/types": "^0.0.1",
"@xo-cash/utils": "file:../utils",
"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

@@ -18,10 +18,13 @@ import { EventEmitter } from "../utils/event-emitter.js";
// TODO: Remove this. Exists to hash the seed for database namespace. // TODO: Remove this. Exists to hash the seed for database namespace.
import { createHash } from "crypto"; import { createHash } from "crypto";
import { p2pkhTemplate } from "@xo-cash/templates";
import { hexToBin } from "@bitauth/libauth"; import { hexToBin } from "@bitauth/libauth";
import { parseTemplate } from "@xo-cash/engine"; import { parseTemplate } from "@xo-cash/engine";
import { p2pkhTemplate } from "@xo-cash/templates";
import { vendingMachineTemplate } from "../templates/vending-machine.js";
import { wrapBCHTemplate } from "../templates/wrap-template.js";
export type AppEventMap = { export type AppEventMap = {
"invitation-added": Invitation; "invitation-added": Invitation;
"invitation-removed": Invitation; "invitation-removed": Invitation;
@@ -53,6 +56,12 @@ export class AppService extends EventEmitter<AppEventMap> {
public settings: SettingsService; public settings: SettingsService;
public invitations: Invitation[] = []; public invitations: Invitation[] = [];
/**
* Incremented whenever the invitation list or any invitation's data/status changes.
* Used by TUI hooks so useSyncExternalStore snapshots change on in-place mutations.
*/
public invitationsRevision = 0;
private invitationRevisions = new Map<string, number>();
private invitationEventCleanup = new Map< private invitationEventCleanup = new Map<
string, string,
{ {
@@ -82,6 +91,8 @@ export class AppService extends EventEmitter<AppEventMap> {
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here // TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
// Import the default P2PKH template // Import the default P2PKH template
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
await engine.importTemplate(vendingMachineTemplate);
await engine.importTemplate(wrapBCHTemplate);
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes // Update all the unspents for every template, and subscribe to the locking bytecodes for changes
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS. // TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
@@ -160,8 +171,9 @@ export class AppService extends EventEmitter<AppEventMap> {
// Create the invitation // Create the invitation
const invitationInstance = await Invitation.create(invitation, deps); const invitationInstance = await Invitation.create(invitation, deps);
// Add the invitation to the invitations array // Attach listeners before SSE connects so updates are not missed.
await this.addInvitation(invitationInstance); await this.addInvitation(invitationInstance);
await invitationInstance.start();
return invitationInstance; return invitationInstance;
} }

View File

@@ -3,7 +3,6 @@ import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state"; import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state";
import type { import type {
XOInvitation, XOInvitation,
XOInvitationCommit,
XOInvitationInput, XOInvitationInput,
XOInvitationOutput, XOInvitationOutput,
XOInvitationVariableValue, XOInvitationVariableValue,

View File

@@ -1,10 +1,9 @@
import type { import type {
AcceptInvitationParameters, InvitationParameters,
AppendInvitationParameters,
Engine, Engine,
GetSpendableResourcesParameters, GetSpendableResourcesParameters,
} from "@xo-cash/engine"; } from "@xo-cash/engine";
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine"; import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation } from "@xo-cash/engine";
import type { import type {
XOInvitation, XOInvitation,
XOInvitationCommit, XOInvitationCommit,
@@ -15,10 +14,6 @@ import type {
} from "@xo-cash/types"; } from "@xo-cash/types";
import type { UnspentOutputData } from "@xo-cash/state"; import type { UnspentOutputData } from "@xo-cash/state";
import { import {
bigIntToBinUint64LE,
bigIntToBinUintBE,
bigIntToBinUintLE,
bigIntToVmNumber,
binToHex, binToHex,
encodeTransaction, encodeTransaction,
generateTransaction, generateTransaction,
@@ -90,13 +85,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
} }
// engine invitation (I have no idea if this is required) // engine invitation (I have no idea if this is required)
const engineInvitation = await dependencies.engine.acceptInvitation(invitation); const engineInvitation = await dependencies.engine.importInvitation(serializeInvitation(invitation));
// Create the invitation // Create the invitation
const invitationInstance = new Invitation(engineInvitation, dependencies); const invitationInstance = new Invitation(engineInvitation, dependencies);
// Start the invitation and its tracking // Start the invitation and its tracking
await invitationInstance.start(); invitationInstance.start();
return invitationInstance; return invitationInstance;
} }
@@ -387,7 +382,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/** /**
* Accept the invitation * Accept the invitation
*/ */
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> { async accept(acceptParams?: InvitationParameters): Promise<void> {
// Accept the invitation // Accept the invitation
this.data = await this.engine.acceptInvitation(this.data, acceptParams); this.data = await this.engine.acceptInvitation(this.data, acceptParams);
@@ -438,7 +433,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/** /**
* Append a commit to the invitation * Append a commit to the invitation
*/ */
async append(data: AppendInvitationParameters): Promise<void> { 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
}
// Append the commit to the invitation // 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);

View File

@@ -1,3 +1,4 @@
import { OracleClient } from '@generalprotocols/oracle-client';
import { EventEmitter } from '../utils/event-emitter.js'; import { EventEmitter } from '../utils/event-emitter.js';
import { import {
type RatesEventMap, type RatesEventMap,
@@ -73,8 +74,17 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
settings: SettingsService, settings: SettingsService,
adapter?: RatesAdapter, adapter?: RatesAdapter,
): Promise<RatesService> { ): Promise<RatesService> {
const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings)); if (adapter) {
return new RatesService(resolvedAdapter, settings); return new RatesService(adapter, settings);
}
const oracleClient = new OracleClient();
oracleClient.start();
const ratesOracle = new RatesOracle(oracleClient, settings);
ratesOracle.start();
return new RatesService(ratesOracle, settings);
} }
/** /**

View File

@@ -0,0 +1,277 @@
import type { XOTemplate } from '@xo-cash/types';
/**
* Vending machine payment template.
*
* Merchant creates a purchaseItems invitation with receipt variables;
* 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'],
defaults: {
change: {
output: 'changeOutput',
role: 'merchant',
generate: ['merchantKey'],
},
},
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: '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: {},
},
},
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: [
{
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: [],
},
},
],
},
],
};

View File

@@ -71,7 +71,7 @@ export function AppProvider({
}); });
// Start the AppService (loads existing invitations) // Start the AppService (loads existing invitations)
await service.start(); service.start();
// Set the service and mark as initialized // Set the service and mark as initialized
setAppService(service); setAppService(service);

View File

@@ -158,9 +158,7 @@ export function SeedInputScreen(): React.ReactElement {
setSeedPhrase(''); setSeedPhrase('');
setSaveMnemonicChecked(false); setSaveMnemonicChecked(false);
setTimeout(() => { navigate('wallet');
navigate('wallet');
}, 500);
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : 'Failed to initialize wallet'; error instanceof Error ? error.message : 'Failed to initialize wallet';

View File

@@ -45,7 +45,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
private targetDenominatorUnitCode: string = 'BCH'; private targetDenominatorUnitCode: string = 'BCH';
private unsubscribeFromSettings: OffCallback | null = null; private unsubscribeFromSettings: OffCallback | null = null;
private constructor(client: OracleClient, settings: SettingsService) { public constructor(client: OracleClient, settings: SettingsService) {
super(); super();
this.client = client; this.client = client;

View File

@@ -1,6 +1,7 @@
import type { XOInvitation } from "@xo-cash/types"; import type { XOInvitation } from "@xo-cash/types";
import { EventEmitter } from "./event-emitter.js"; import { EventEmitter } from "./event-emitter.js";
import { SSESession, type SSEvent } from "./sse-client.js"; // import { SSESession, type SSEvent } from "./sse-client.js";
import { SSESession, type SSEvent } from "@xo-cash/utils";
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
export type SyncServerEventMap = { export type SyncServerEventMap = {
@@ -38,7 +39,6 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
}, },
// Create our event bubblers // Create our event bubblers
onMessage: (event: SSEvent) => this.emit("message", event),
onError: (error: unknown) => onError: (error: unknown) =>
this.emit( this.emit(
"error", "error",
@@ -48,6 +48,8 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
onConnected: () => this.emit("connected", undefined), onConnected: () => this.emit("connected", undefined),
}, },
); );
this.sse.on("message", (event: SSEvent) => this.emit("message", event));
} }
/** /**
@@ -63,7 +65,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
*/ */
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
// Disconnect from the SSE Session // Disconnect from the SSE Session
this.sse.close(); await this.sse.disconnect();
} }
/** /**