Update to latest engine. Add sync-v2. Various fixes.

This commit is contained in:
2026-09-07 03:33:44 +00:00
parent 051fc0c9ac
commit f01f9bc56b
31 changed files with 5977 additions and 1392 deletions
+70 -26
View File
@@ -1,6 +1,8 @@
import {
ElectrumBlockchainProvider,
Engine,
type XOEngineOptions,
computeAccountHash,
// This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing
generateTemplateIdentifier,
} from "@xo-cash/engine";
@@ -19,11 +21,12 @@ import { EventEmitter } from "../utils/event-emitter.js";
// TODO: Remove this. Exists to hash the seed for database namespace.
import { createHash } from "crypto";
import { hexToBin } from "@bitauth/libauth";
import { parseTemplate } from "@xo-cash/engine";
import { parseTemplate, BlockchainMonitor } from "@xo-cash/engine";
import { p2pkhTemplate } from "@xo-cash/templates";
import { vendingMachineTemplate } from "../templates/vending-machine.js";
import { wrapBCHTemplate } from "../templates/wrap-template.js";
import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
export type AppEventMap = {
"invitation-added": Invitation;
@@ -54,6 +57,7 @@ export class AppService extends EventEmitter<AppEventMap> {
public electrum: BlockchainService;
public rates: RatesService;
public settings: SettingsService;
public state: State;
public invitations: Invitation[] = [];
/**
@@ -79,16 +83,45 @@ export class AppService extends EventEmitter<AppEventMap> {
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
const seedHash = createHash("sha256").update(seed).digest("hex");
const accountHash = computeAccountHash(seed);
// The v2 client derives an isolated signing key from this key for each invitation resource.
const syncPrivateKey = hexToBin(seedHash);
// We want to only prefix the file name
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
// Create the engine
const engine = await Engine.create(seed, {
...config.engineConfig,
databaseFilename: prefixedStoragePath,
// Initialize the blockchain provider using Electrum
const blockchainProvider = new ElectrumBlockchainProvider({
applicationIdentifier: config.electrumApplicationIdentifier ?? 'XO-CLI',
electrumOptions: config.engineConfig,
});
// Start the blockchain provider (do this async, otherwise we block startup - the data is event-based so we dont need to await it)
blockchainProvider.initialize();
// Create the storage adapter
const storageAdapter = await createStorageAdapter({
storageType: config.engineConfig.storageType ?? StorageType.INDEXEDDB,
databasePath: config.engineConfig.databasePath,
databaseFilename: prefixedStoragePath,
accountHash,
});
// Create the state instance
const state = new State(storageAdapter);
// Initialize the blockchain monitor, event listeners for electrum application events
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
blockchainMonitor.initializeEventListeners();
// Create the engine
// TODO: Remove this type assertion. Make Engine constructor public OR allow full pre-defined dependency injection to `.create()`
const engine = new Engine(seed, state, blockchainProvider, blockchainMonitor);
// Initialize the state sync server.
await engine.initializeStateSync();
// 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);
@@ -100,14 +133,15 @@ export class AppService extends EventEmitter<AppEventMap> {
const updateTemplates = async () => {
const templates = await engine.listImportedTemplates();
templates.forEach(async (template) => {
engine.updateUnspentOutputsForTemplate(
generateTemplateIdentifier(template),
);
engine.subscribeToScriptHashForTemplate(
generateTemplateIdentifier(template),
);
});
// maaan, I have no clue if this is required still
// templates.forEach(async (template) => {
// engine.updateUnspentOutputsForTemplate(
// generateTemplateIdentifier(template),
// );
// engine.subscribeToScriptHashForTemplate(
// generateTemplateIdentifier(template),
// );
// });
};
updateTemplates();
@@ -115,11 +149,11 @@ export class AppService extends EventEmitter<AppEventMap> {
// Set default locking parameters for P2PKH
// 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?
await engine.setDefaultLockingParameters(
generateTemplateIdentifier(parseTemplate(p2pkhTemplate)),
"receiveOutput",
"receiver",
);
await engine.updateFallbackLockingParameters({
templateIdentifier: generateTemplateIdentifier(parseTemplate(p2pkhTemplate)),
outputIdentifier: "receiveOutput",
roleIdentifier: "receiver",
});
// Create our own storage for the invitations
const storage = await Storage.create(config.invitationStoragePath);
@@ -139,6 +173,8 @@ export class AppService extends EventEmitter<AppEventMap> {
electrum,
rates,
settings,
state,
syncPrivateKey,
);
}
@@ -149,6 +185,8 @@ export class AppService extends EventEmitter<AppEventMap> {
electrum: BlockchainService,
rates: RatesService,
settings: SettingsService,
state: State,
private readonly syncPrivateKey: Uint8Array,
) {
super();
@@ -158,7 +196,8 @@ export class AppService extends EventEmitter<AppEventMap> {
this.electrum = electrum;
this.rates = rates;
this.settings = settings;
this.history = new HistoryService(engine, this.invitations);
this.state = state;
this.history = new HistoryService(engine, this.invitations, state);
}
async createInvitation(
@@ -171,6 +210,7 @@ export class AppService extends EventEmitter<AppEventMap> {
typeof invitation === "string"
? invitation
: invitation.invitationIdentifier,
this.syncPrivateKey,
);
const deps = {
@@ -307,14 +347,18 @@ export class AppService extends EventEmitter<AppEventMap> {
byInvitation.set(output.reservedBy!, existing);
}
// console.error('Unreserving resources is not currently supported by the engine')
for (const [invitationIdentifier, outputs] of byInvitation) {
await this.engine.unreserveResources(
outputs.map((o) => ({
outpointTransactionHash: hexToBin(o.outpointTransactionHash),
outpointIndex: o.outpointIndex,
})),
invitationIdentifier,
);
// Remove them directly from state
this.state.archiveInvitation(invitationIdentifier);
// await this.engine.unreserveResources(
// outputs.map((o) => ({
// outpointTransactionHash: hexToBin(o.outpointTransactionHash),
// outpointIndex: o.outpointIndex,
// })),
// invitationIdentifier,
// );
}
return reserved.length;
+3 -2
View File
@@ -93,6 +93,7 @@ export class HistoryService {
constructor(
private engine: Engine,
private invitations: Invitation[],
private state: State,
) {}
/**
@@ -186,7 +187,7 @@ export class HistoryService {
for (const templateIdentifier of templateIdentifiers) {
const scriptHashDataList =
await this.engine.listScriptHashesForTemplate(templateIdentifier);
await this.engine.listScriptHashData({ templateIdentifier });
for (const scriptHashData of scriptHashDataList) {
scriptHashDataByScriptHash.set(
scriptHashData.scriptHash,
@@ -690,7 +691,7 @@ export class HistoryService {
private async getScriptHashData(
scriptHash: string,
): Promise<ScriptHashData | undefined> {
return (this.engine as unknown as { state: State }).state.getScriptHashData(
return this.state.getScriptHashData(
scriptHash,
);
}
+82 -21
View File
@@ -1,16 +1,12 @@
import type {
InvitationParameters,
Engine,
GetSpendableResourcesParameters,
InvitationParameters,
} from "@xo-cash/engine";
import {
generateTemplateIdentifier,
hasInvitationExpired,
mergeInvitationCommits,
resolveCommitReferences,
serializeInvitation,
deserializeInvitation,
type ResolvedInvitationData,
} from "@xo-cash/engine";
import type {
XOInvitation,
@@ -20,6 +16,7 @@ import type {
XOInvitationVariable,
XOInvitationVariableValue,
XOTemplate,
XOTemplateActionIntent,
} from "@xo-cash/types";
import type { UnspentOutputData } from "@xo-cash/state";
import {
@@ -39,12 +36,13 @@ import { EventEmitter } from "../utils/event-emitter.js";
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
import { compileCashAssemblyString } from "@xo-cash/engine";
export type { ResolvedInvitationData } from "@xo-cash/engine";
import type { ResolvedInvitationData } from "../utils/resolve-invitation-data.js";
import { resolveCommitReferences } from "../utils/resolve-invitation-data.js";
export type InvitationEventMap = {
"invitation-updated": XOInvitation;
"invitation-status-changed": string;
"invitation-removed": void;
"invitation-removed": string;
error: Error;
};
@@ -55,6 +53,11 @@ export type InvitationDependencies = {
electrum: BlockchainService;
};
export type FeeAwareChangeResult = {
changeAmountSatoshis: bigint;
feeSatoshis: bigint;
};
function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation {
const { entityIdentifier: _entityIdentifier, ...sharedInvitation } =
invitation as XOInvitation & { entityIdentifier?: string };
@@ -103,10 +106,15 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
}
// engine invitation (I have no idea if this is required)
const engineInvitation = await dependencies.engine.importInvitation(
serializeInvitation(invitation),
);
const ensureEngineInvitation = async (invitation: XOInvitation) => {
try {
return await dependencies.engine.getInvitationOrThrow(invitation.invitationIdentifier);
} catch {
return await dependencies.engine.acceptInvitation(invitation);
}
}
const engineInvitation = await ensureEngineInvitation(invitation);
// Create the invitation
const invitationInstance = new Invitation(
@@ -240,14 +248,17 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Prefer keeping the engine's local invitation state in sync.
this.updateInvitationData(
stripLocalInvitationMetadata(
await this.engine.updateInvitation({
...this.data,
...invitation,
commits: combinedCommits,
}),
await this.engine.updateInvitation(
{
...this.data,
...invitation,
commits: combinedCommits,
}
),
),
);
} catch (error) {
console.error(error);
this.emit(
"error",
error instanceof Error ? error : new Error(String(error)),
@@ -273,8 +284,8 @@ 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 async handleSSEMessage(event: SSEvent): Promise<void> {
const invitation = this.parseInvitationFromSSEMessage(event);
private async handleSSEMessage(event: { event: "invitation-updated"; data: XOInvitation }): Promise<void> {
const invitation = event.data;
if (
!invitation ||
invitation.invitationIdentifier !== this.data.invitationIdentifier
@@ -296,6 +307,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
),
);
} catch (error) {
console.error(error);
this.emit(
"error",
error instanceof Error ? error : new Error(String(error)),
@@ -320,7 +332,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const decoded = decodeExtendedJsonObject(payload) as XOInvitation;
return stripLocalInvitationMetadata(
deserializeInvitation(serializeInvitation(decoded)),
decoded,
);
} catch {
return null;
@@ -587,6 +599,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* @returns The transaction hash returned by the network after broadcast.
*/
async broadcast(): Promise<string> {
const engineInvitation = await this.engine.getInvitationOrThrow(this.data.invitationIdentifier);
console.log(engineInvitation);
const txHash = await this.engine.executeAction(
this.data.invitationIdentifier,
{
@@ -658,6 +674,51 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.publishInvitation(this.data);
}
/**
* Ask the engine to calculate and append fee-aware change for the current
* merged invitation. If no spendable change can be created, the remaining
* surplus is left as the miner fee.
*/
async addFeeAwareChange(): Promise<FeeAwareChangeResult> {
const availability = await this.engine.getChangeAvailability(
this.data.invitationIdentifier,
);
const availableFeeSatoshis =
availability.inputTotalSatoshis - availability.outputTotalSatoshis;
if (availableFeeSatoshis < availability.estimatedMinFeeSatoshis) {
throw new Error(
`Insufficient funds for miner fee. Available ${availableFeeSatoshis} satoshis, estimated minimum ${availability.estimatedMinFeeSatoshis} satoshis.`,
);
}
if (!availability.canAddChange) {
return {
changeAmountSatoshis: 0n,
feeSatoshis: availableFeeSatoshis,
};
}
this.updateInvitationData(
await this.engine.addChangeToInvitation(this.data.invitationIdentifier),
);
await this.publishInvitation(this.data);
await this.storage.set(this.data.invitationIdentifier, this.data);
await this.updateStatus();
const finalAvailability = await this.engine.getChangeAvailability(
this.data.invitationIdentifier,
);
return {
changeAmountSatoshis: availability.changeAmountSatoshis,
feeSatoshis:
finalAvailability.inputTotalSatoshis -
finalAvailability.outputTotalSatoshis,
};
}
async addVariables(variables: XOInvitationVariable[]): Promise<void> {
// Add the variables to the invitation
await this.append({ variables });
@@ -751,7 +812,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/**
* Get the starting actions for the invitation
*/
async getStartingActions() {
async getStartingActions(): Promise<XOTemplateActionIntent[]> {
return this.engine.listStartingActions(this.data.templateIdentifier);
}
@@ -895,7 +956,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.storage.remove(this.data.invitationIdentifier);
// Remove the invitation from the engine's internal db
await this.engine.DANGEROUS_deleteStoredInvitation(
await this.engine.archiveInvitation(
this.data.invitationIdentifier,
);