Use xo-cash/utils sse. Add vending machine template. Greatly improve startup times.

This commit is contained in:
2026-05-24 19:35:50 +02:00
parent def261b568
commit 85746c3306
11 changed files with 367 additions and 31 deletions

View File

@@ -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<AppEventMap> {
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<string, number>();
private invitationEventCleanup = new Map<
string,
{
@@ -82,7 +91,9 @@ 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
// 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<AppEventMap> {
// 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;
}

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 {
XOInvitation,
XOInvitationCommit,
XOInvitationInput,
XOInvitationOutput,
XOInvitationVariableValue,

View File

@@ -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<InvitationEventMap> {
}
// 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<InvitationEventMap> {
/**
* Accept the invitation
*/
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
async accept(acceptParams?: InvitationParameters): Promise<void> {
// Accept the invitation
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
*/
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
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 {
type RatesEventMap,
@@ -73,8 +74,17 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
settings: SettingsService,
adapter?: RatesAdapter,
): Promise<RatesService> {
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);
}
/**

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)
await service.start();
service.start();
// Set the service and mark as initialized
setAppService(service);

View File

@@ -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';

View File

@@ -45,7 +45,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
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;

View File

@@ -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<SyncServerEventMap> {
},
// 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<SyncServerEventMap> {
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> {
// Disconnect from the SSE Session
this.sse.close();
await this.sse.disconnect();
}
/**