From 85746c33067efec09d8dba9656a7a078e714c5f6 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sun, 24 May 2026 19:35:50 +0200 Subject: [PATCH] Use xo-cash/utils sse. Add vending machine template. Greatly improve startup times. --- package-lock.json | 48 +++++- package.json | 1 + src/services/app.ts | 18 +- src/services/history.ts | 1 - src/services/invitation.ts | 23 +-- src/services/rates.ts | 14 +- src/templates/vending-machine.ts | 277 +++++++++++++++++++++++++++++++ src/tui/hooks/useAppContext.tsx | 2 +- src/tui/screens/SeedInput.tsx | 4 +- src/utils/rates/rates-oracles.ts | 2 +- src/utils/sync-server.ts | 8 +- 11 files changed, 367 insertions(+), 31 deletions(-) create mode 100644 src/templates/vending-machine.ts diff --git a/package-lock.json b/package-lock.json index 545500d..324da2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@xo-cash/state": "file:../state", "@xo-cash/templates": "file:../templates", "@xo-cash/types": "^0.0.1", + "@xo-cash/utils": "file:../utils", "better-sqlite3": "^12.6.2", "clipboardy": "^5.1.0", "ink": "^6.6.0", @@ -47,16 +48,16 @@ "license": "MIT", "dependencies": { "@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/protocol": "^2.3.1", "@electrum-cash/servers": "^3.1.0", - "@xo-cash/crypto": "^0.0.1", - "@xo-cash/primitives": "0.0.1", - "@xo-cash/state": "0.0.2", + "@xo-cash/crypto": "0.0.1", + "@xo-cash/primitives": "file:../primitives", + "@xo-cash/state": "file:../state", "@xo-cash/templates": "0.0.1", - "@xo-cash/types": "0.0.1", - "@xo-cash/utils": "0.0.1", + "@xo-cash/types": "^0.0.1-development.14519184304", + "@xo-cash/utils": "^0.0.1-development.14519184505", "eventemitter3": "^5.0.1" }, "devDependencies": { @@ -140,6 +141,37 @@ "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": { "version": "0.2.4", "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_modules/@xo-cash/utils": { + "resolved": "../utils", + "link": true + }, "node_modules/ansi-escapes": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", diff --git a/package.json b/package.json index 00e6ed2..a970123 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@xo-cash/state": "file:../state", "@xo-cash/templates": "file:../templates", "@xo-cash/types": "^0.0.1", + "@xo-cash/utils": "file:../utils", "better-sqlite3": "^12.6.2", "clipboardy": "^5.1.0", "ink": "^6.6.0", diff --git a/src/services/app.ts b/src/services/app.ts index 62f2289..bb5a885 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -18,10 +18,13 @@ import { EventEmitter } from "../utils/event-emitter.js"; // TODO: Remove this. Exists to hash the seed for database namespace. import { createHash } from "crypto"; -import { p2pkhTemplate } from "@xo-cash/templates"; import { hexToBin } from "@bitauth/libauth"; import { 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 = { "invitation-added": Invitation; "invitation-removed": Invitation; @@ -53,6 +56,12 @@ export class AppService extends EventEmitter { public settings: SettingsService; 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(); private invitationEventCleanup = new Map< string, { @@ -82,7 +91,9 @@ export class AppService extends EventEmitter { // 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 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 // TODO: Remove the above lines that do the same thing. Minimising changes for BLISS. const updateTemplates = async () => { @@ -160,8 +171,9 @@ export class AppService extends EventEmitter { // Create the invitation 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 invitationInstance.start(); return invitationInstance; } diff --git a/src/services/history.ts b/src/services/history.ts index f418ba2..3cd09e4 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -3,7 +3,6 @@ import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state"; import type { XOInvitation, - XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariableValue, diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 55f1ba6..490bcbe 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -1,10 +1,9 @@ import type { - AcceptInvitationParameters, - AppendInvitationParameters, + InvitationParameters, Engine, GetSpendableResourcesParameters, } from "@xo-cash/engine"; -import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine"; +import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation } from "@xo-cash/engine"; import type { XOInvitation, XOInvitationCommit, @@ -15,10 +14,6 @@ import type { } from "@xo-cash/types"; import type { UnspentOutputData } from "@xo-cash/state"; import { - bigIntToBinUint64LE, - bigIntToBinUintBE, - bigIntToBinUintLE, - bigIntToVmNumber, binToHex, encodeTransaction, generateTransaction, @@ -90,13 +85,13 @@ export class Invitation extends EventEmitter { } // 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 const invitationInstance = new Invitation(engineInvitation, dependencies); // Start the invitation and its tracking - await invitationInstance.start(); + invitationInstance.start(); return invitationInstance; } @@ -387,7 +382,7 @@ export class Invitation extends EventEmitter { /** * Accept the invitation */ - async accept(acceptParams?: AcceptInvitationParameters): Promise { + async accept(acceptParams?: InvitationParameters): Promise { // Accept the invitation this.data = await this.engine.acceptInvitation(this.data, acceptParams); @@ -438,7 +433,13 @@ export class Invitation extends EventEmitter { /** * Append a commit to the invitation */ - async append(data: AppendInvitationParameters): Promise { + async append(data: InvitationParameters): Promise { + 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 this.data = await this.engine.appendInvitation(this.data.invitationIdentifier, data); diff --git a/src/services/rates.ts b/src/services/rates.ts index 2d4719a..a03e6ca 100644 --- a/src/services/rates.ts +++ b/src/services/rates.ts @@ -1,3 +1,4 @@ +import { OracleClient } from '@generalprotocols/oracle-client'; import { EventEmitter } from '../utils/event-emitter.js'; import { type RatesEventMap, @@ -73,8 +74,17 @@ export class RatesService extends EventEmitter { settings: SettingsService, adapter?: RatesAdapter, ): Promise { - const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings)); - return new RatesService(resolvedAdapter, settings); + if (adapter) { + return new RatesService(adapter, settings); + } + + const oracleClient = new OracleClient(); + oracleClient.start(); + + const ratesOracle = new RatesOracle(oracleClient, settings); + ratesOracle.start(); + + return new RatesService(ratesOracle, settings); } /** diff --git a/src/templates/vending-machine.ts b/src/templates/vending-machine.ts new file mode 100644 index 0000000..48c6600 --- /dev/null +++ b/src/templates/vending-machine.ts @@ -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: $() for $() sats', + icon: 'request', + + roles: { + merchant: { + name: 'Sell Items', + description: 'Receive payment for $()', + icon: 'request', + requirements: { + secrets: ['merchantKey'], + variables: [ + 'totalSatoshis', + 'orderId', + 'merchantName', + 'receiptSummary', + 'lineItemsJson', + ], + }, + }, + customer: { + name: 'Pay', + description: 'Pay $() sats for $()', + 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 $(): $()', + icon: 'request', + + roles: { + merchant: { + name: 'Received Payment', + description: 'Received $() sats from $() sale', + icon: 'receive', + }, + customer: { + name: 'Sent Payment', + description: 'Paid $() sats for $()', + 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: '$() sats to $()', + icon: 'request', + + roles: { + merchant: { + name: 'Payment Received', + description: 'Received $() sats for $()', + }, + customer: { + name: 'Payment Sent', + description: 'Sent $() sats for $()', + }, + }, + + lockingScript: 'merchantReceivingLockingScript', + valueSatoshis: '$()', + 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 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG', + unlockMerchantP2PKH: + ' ', + }, + + 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: [], + }, + }, + ], + }, + ], +}; diff --git a/src/tui/hooks/useAppContext.tsx b/src/tui/hooks/useAppContext.tsx index 619e3be..5b43c80 100644 --- a/src/tui/hooks/useAppContext.tsx +++ b/src/tui/hooks/useAppContext.tsx @@ -71,7 +71,7 @@ export function AppProvider({ }); // Start the AppService (loads existing invitations) - await service.start(); + service.start(); // Set the service and mark as initialized setAppService(service); diff --git a/src/tui/screens/SeedInput.tsx b/src/tui/screens/SeedInput.tsx index ecb1c81..c83755e 100644 --- a/src/tui/screens/SeedInput.tsx +++ b/src/tui/screens/SeedInput.tsx @@ -158,9 +158,7 @@ export function SeedInputScreen(): React.ReactElement { setSeedPhrase(''); setSaveMnemonicChecked(false); - setTimeout(() => { - navigate('wallet'); - }, 500); + navigate('wallet'); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to initialize wallet'; diff --git a/src/utils/rates/rates-oracles.ts b/src/utils/rates/rates-oracles.ts index dc82dda..12fb468 100644 --- a/src/utils/rates/rates-oracles.ts +++ b/src/utils/rates/rates-oracles.ts @@ -45,7 +45,7 @@ export class RatesOracle extends BaseRates { private targetDenominatorUnitCode: string = 'BCH'; private unsubscribeFromSettings: OffCallback | null = null; - private constructor(client: OracleClient, settings: SettingsService) { + public constructor(client: OracleClient, settings: SettingsService) { super(); this.client = client; diff --git a/src/utils/sync-server.ts b/src/utils/sync-server.ts index 03fe558..d381271 100644 --- a/src/utils/sync-server.ts +++ b/src/utils/sync-server.ts @@ -1,6 +1,7 @@ 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 "./sse-client.js"; +import { SSESession, type SSEvent } from "@xo-cash/utils"; import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; export type SyncServerEventMap = { @@ -38,7 +39,6 @@ export class SyncServer extends EventEmitter { }, // Create our event bubblers - onMessage: (event: SSEvent) => this.emit("message", event), onError: (error: unknown) => this.emit( "error", @@ -48,6 +48,8 @@ export class SyncServer extends EventEmitter { onConnected: () => this.emit("connected", undefined), }, ); + + this.sse.on("message", (event: SSEvent) => this.emit("message", event)); } /** @@ -63,7 +65,7 @@ export class SyncServer extends EventEmitter { */ async disconnect(): Promise { // Disconnect from the SSE Session - this.sse.close(); + await this.sse.disconnect(); } /**