Update to latest engine. Add sync-v2. Various fixes.
This commit is contained in:
Generated
+3865
-916
File diff suppressed because it is too large
Load Diff
+13
-8
@@ -9,11 +9,12 @@
|
||||
"xo-complete": "./dist/cli/autocomplete/complete.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||
"dev": "SYNC_SERVER_URL=https://v2.sync.xo.harvmaster.com tsx src/index.ts",
|
||||
"build": "tsc && npm run build:copy-scripts",
|
||||
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
|
||||
"build:unsafe": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts",
|
||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||
"syntax": "tsc --noEmit",
|
||||
"start": "SYNC_SERVER_URL=https://v2.sync.xo.harvmaster.com node dist/index.js",
|
||||
"test": "vitest --run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
@@ -30,19 +31,23 @@
|
||||
"cli",
|
||||
"tui"
|
||||
],
|
||||
"overrides": {
|
||||
"@xo-cash/utils": "^0.0.5-test.20260831095544025"
|
||||
},
|
||||
"author": "General Protocols",
|
||||
"license": "ISC",
|
||||
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.0.0",
|
||||
"@bitauth/libauth": "^3.1.0-next.8",
|
||||
"@electrum-cash/application": "^0.2.3-development.13447192992",
|
||||
"@electrum-cash/protocol": "^2.3.1",
|
||||
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||
"@xo-cash/crypto": "^0.0.1",
|
||||
"@xo-cash/crypto": "^0.0.2",
|
||||
"@xo-cash/engine": "file:../engine",
|
||||
"@xo-cash/state": "file:../state",
|
||||
"@xo-cash/templates": "file:../templates",
|
||||
"@xo-cash/types": "^0.0.1",
|
||||
"@xo-cash/utils": "file:../utils",
|
||||
"@xo-cash/state": "^0.0.3",
|
||||
"@xo-cash/templates": "^0.0.3",
|
||||
"@xo-cash/types": "^0.0.5",
|
||||
"@xo-cash/utils": "^0.0.5-test.20260831095544025",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"clipboardy": "^5.1.0",
|
||||
"ink": "^6.6.0",
|
||||
|
||||
+3
-2
@@ -13,7 +13,7 @@ import { getDataDir } from "./utils/paths.js";
|
||||
* Configuration options for the CLI application.
|
||||
*/
|
||||
export interface AppConfig {
|
||||
/** URL of the sync server (default: http://localhost:3000) */
|
||||
/** URL of the sync server (default: https://v2.sync.xo.harvmaster.com) */
|
||||
syncServerUrl: string;
|
||||
/** Database path for wallet state storage */
|
||||
databasePath: string;
|
||||
@@ -51,7 +51,8 @@ export class App {
|
||||
const dataDir = getDataDir();
|
||||
// Set default configuration
|
||||
const fullConfig: AppConfig = {
|
||||
syncServerUrl: config.syncServerUrl ?? "http://localhost:3000",
|
||||
syncServerUrl:
|
||||
config.syncServerUrl ?? "https://v2.sync.xo.harvmaster.com",
|
||||
databasePath: config.databasePath ?? dataDir,
|
||||
databaseFilename: config.databaseFilename ?? "xo-wallet.db",
|
||||
invitationStoragePath:
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ npx tsx src/index.ts # TUI
|
||||
| Variable | Default |
|
||||
| ------------------------- | --------------------------------------- |
|
||||
| `XO_CONFIG_DIR` | `~/.config/xo-cli` |
|
||||
| `SYNC_SERVER_URL` | `http://localhost:3000` |
|
||||
| `SYNC_SERVER_URL` | `https://v2.sync.xo.harvmaster.com` |
|
||||
| `DB_PATH` | `$XO_CONFIG_DIR/data` |
|
||||
| `DB_FILENAME` | `xo-wallet.db` |
|
||||
| `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` |
|
||||
|
||||
@@ -18,7 +18,6 @@ import type { XOInvitation } from "@xo-cash/types";
|
||||
import { resolveTemplate } from "../utils.js";
|
||||
|
||||
const DEFAULT_FEE = 500n;
|
||||
const DUST_THRESHOLD = 546n;
|
||||
|
||||
/**
|
||||
* Serializes an invitation to pretty-printed JSON for file export.
|
||||
@@ -216,69 +215,6 @@ async function buildAppendParams(
|
||||
`Outputs: ${formatObject(outputs.map((o) => o.outputIdentifier))}`,
|
||||
);
|
||||
|
||||
// --- Auto change output ---
|
||||
// When inputs are provided, look up each UTXO's value, compute the
|
||||
// required sats, and return the excess minus fees back to the user.
|
||||
if (inputs.length > 0) {
|
||||
const allUtxos = await deps.app.engine.listUnspentOutputsData();
|
||||
const utxoMap = new Map(
|
||||
allUtxos.map((u) => [
|
||||
`${u.outpointTransactionHash}:${u.outpointIndex}`,
|
||||
u,
|
||||
]),
|
||||
);
|
||||
|
||||
// Sum the total input sats
|
||||
let totalInputSats = 0n;
|
||||
// Iterate through the inputs and sum the valueSatoshis
|
||||
for (const input of inputs) {
|
||||
// Get the tx hash hex
|
||||
const txHashHex = binToHex(input.outpointTransactionHash);
|
||||
// Get the utxo from the utxo map
|
||||
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
|
||||
if (!utxo) {
|
||||
// If the utxo is not found, print a message and return null
|
||||
deps.io.err(
|
||||
`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// Sum the valueSatoshis
|
||||
totalInputSats += BigInt(utxo.valueSatoshis);
|
||||
}
|
||||
deps.io.verbose(`Total input value: ${totalInputSats} satoshis`);
|
||||
|
||||
// Get the required sats out
|
||||
const requiredSats = await invitation.getSatsOut();
|
||||
deps.io.verbose(`Required output value: ${requiredSats} satoshis`);
|
||||
|
||||
// Get the change amount by subtracting the required sats out from the total input sats and the default fee
|
||||
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
|
||||
deps.io.verbose(
|
||||
`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`,
|
||||
);
|
||||
|
||||
// If the change amount is less than 0, print a message and return null
|
||||
if (changeAmount < 0n) {
|
||||
deps.io.err(
|
||||
`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the change amount is greater than or equal to the dust threshold, add the change output
|
||||
if (changeAmount >= DUST_THRESHOLD) {
|
||||
outputs.push({ valueSatoshis: changeAmount });
|
||||
deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`);
|
||||
}
|
||||
// If the change amount is greater than 0, print a message
|
||||
else if (changeAmount > 0n) {
|
||||
deps.io.out(
|
||||
`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { inputs, outputs };
|
||||
}
|
||||
|
||||
@@ -482,6 +418,12 @@ export const handleInvitationCommand = async (
|
||||
if (inputs.length > 0 || outputs.length > 0) {
|
||||
await invitationInstance.append({ inputs, outputs });
|
||||
}
|
||||
if (inputs.length > 0) {
|
||||
const feeAwareChange = await invitationInstance.addFeeAwareChange();
|
||||
deps.io.out(
|
||||
`Miner fee: ${feeAwareChange.feeSatoshis} satoshis; change: ${feeAwareChange.changeAmountSatoshis} satoshis`,
|
||||
);
|
||||
}
|
||||
|
||||
// Write the invitation to a file in the working directory
|
||||
// TODO: Support the -o flag to specify the output path
|
||||
@@ -609,6 +551,12 @@ export const handleInvitationCommand = async (
|
||||
if (inputs.length > 0 || outputs.length > 0) {
|
||||
await invitation.append({ inputs, outputs });
|
||||
}
|
||||
if (inputs.length > 0) {
|
||||
const feeAwareChange = await invitation.addFeeAwareChange();
|
||||
deps.io.out(
|
||||
`Miner fee: ${feeAwareChange.feeSatoshis} satoshis; change: ${feeAwareChange.changeAmountSatoshis} satoshis`,
|
||||
);
|
||||
}
|
||||
deps.io.verbose(`Invitation appended: ${formatObject(invitation.data)}`);
|
||||
deps.io.out(`Invitation appended: ${invitationIdentifier}`);
|
||||
|
||||
|
||||
+2
-1
@@ -184,7 +184,8 @@ async function main(): Promise<void> {
|
||||
const app = await AppService.create(
|
||||
mnemonic,
|
||||
{
|
||||
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
|
||||
syncServerUrl:
|
||||
options["syncServerUrl"] ?? "https://v2.sync.xo.harvmaster.com",
|
||||
engineConfig: {
|
||||
databasePath: options["databasePath"] ?? paths.dataDir,
|
||||
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ async function main(): Promise<void> {
|
||||
const dataDir = getDataDir();
|
||||
// Create and start the application
|
||||
await App.create({
|
||||
syncServerUrl: process.env["SYNC_SERVER_URL"] ?? "http://localhost:3000",
|
||||
syncServerUrl:
|
||||
process.env["SYNC_SERVER_URL"] ?? "https://v2.sync.xo.harvmaster.com",
|
||||
databasePath: process.env["DB_PATH"] ?? dataDir,
|
||||
databaseFilename: process.env["DB_FILENAME"] ?? "xo-wallet.db",
|
||||
invitationStoragePath:
|
||||
|
||||
+70
-26
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+320
-228
@@ -1,270 +1,362 @@
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* no starting actions
|
||||
* doesnt track the lockingscript
|
||||
* import locking script and set service as service provider
|
||||
* The template is ahead of the engine
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encodes the user parts of the https://wrapped.cash/ usecase as an XO template.
|
||||
*
|
||||
* The bootstrap ceremony is not encoded in this template, but the final categoryId
|
||||
* is properly encoded here and ensure we are operating with the correct token.
|
||||
*/
|
||||
export const wrapBCHTemplate: XOTemplate = {
|
||||
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
|
||||
$schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json',
|
||||
|
||||
name: "Wrapped BCH",
|
||||
description: "Convert between BCH and wBCH tokens.",
|
||||
icon: "wrap",
|
||||
name: 'Wrapped BCH',
|
||||
description: 'Convert between BCH and wBCH tokens.',
|
||||
icon: 'wrap',
|
||||
|
||||
version: "1",
|
||||
supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
|
||||
version: '1',
|
||||
supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'],
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
name: "User",
|
||||
description: "The person wrapping or unwrapping BCH.",
|
||||
icon: "user",
|
||||
},
|
||||
},
|
||||
resources: [
|
||||
{
|
||||
name: 'Official Website',
|
||||
description: 'Official homepage for the wBCH token.',
|
||||
|
||||
start: [
|
||||
{
|
||||
action: "wrap",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
action: "unwrap",
|
||||
role: "user",
|
||||
},
|
||||
],
|
||||
url: 'https://wrapped.cash/',
|
||||
}
|
||||
],
|
||||
|
||||
actions: {
|
||||
wrap: {
|
||||
name: "Wrap BCH",
|
||||
description: "Convert BCH into wBCH tokens.",
|
||||
icon: "wrap",
|
||||
roles: {
|
||||
wrapper: {
|
||||
name: 'Wrapper',
|
||||
description: 'The person wrapping BCH into wBCH.',
|
||||
icon: 'user',
|
||||
},
|
||||
unwrapper: {
|
||||
name: 'Unwrapper',
|
||||
description: 'The person unwrapping wBCH into BCH.',
|
||||
icon: 'user',
|
||||
},
|
||||
service: {
|
||||
name: 'Provider',
|
||||
description: 'The application providing the wrapping service.',
|
||||
icon: 'contract',
|
||||
},
|
||||
},
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
requirements: {
|
||||
variables: ["amountToWrap", "recipientLockingScript"],
|
||||
},
|
||||
},
|
||||
},
|
||||
// The engine has no knowledge of the covenant UTXOs and therefor cannot initiate wrap/unwrap, so the starting actions have been omitted.
|
||||
start: [],
|
||||
|
||||
requirements: {
|
||||
participants: [{ role: "user", slots: { min: 1, max: 1 } }],
|
||||
},
|
||||
actions: {
|
||||
wrap: {
|
||||
name: 'Wrap BCH',
|
||||
description: 'Convert BCH into wBCH tokens.',
|
||||
icon: 'wrap',
|
||||
|
||||
transaction: "wrapTransaction",
|
||||
},
|
||||
roles: {
|
||||
service: {
|
||||
requirements: {
|
||||
variables: ['direction', 'poolSatoshis', 'poolTokens'],
|
||||
},
|
||||
},
|
||||
wrapper: {
|
||||
requirements: {
|
||||
variables: ['amountToWrap', 'recipientLockingScript'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
unwrap: {
|
||||
name: "Unwrap wBCH",
|
||||
description: "Convert wBCH tokens back into BCH.",
|
||||
icon: "unwrap",
|
||||
requirements: {
|
||||
participants: [
|
||||
{ role: 'service', slots: { min: 1, max: 1 } },
|
||||
{ role: 'wrapper', slots: { min: 1, max: 1 } }
|
||||
],
|
||||
},
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
requirements: {
|
||||
variables: ["amountToUnwrap", "recipientLockingScript"],
|
||||
},
|
||||
},
|
||||
},
|
||||
transaction: 'wrapTransaction',
|
||||
},
|
||||
|
||||
requirements: {
|
||||
participants: [{ role: "user", slots: { min: 1, max: 1 } }],
|
||||
},
|
||||
unwrap: {
|
||||
name: 'Unwrap wBCH',
|
||||
description: 'Convert wBCH tokens back into BCH.',
|
||||
icon: 'unwrap',
|
||||
|
||||
transaction: "unwrapTransaction",
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
service: {
|
||||
requirements: {
|
||||
variables: ['direction', 'poolSatoshis', 'poolTokens'],
|
||||
},
|
||||
},
|
||||
unwrapper: {
|
||||
requirements: {
|
||||
variables: ['amountToUnwrap', 'recipientLockingScript'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
transactions: {
|
||||
wrapTransaction: {
|
||||
name: "Wrapped BCH",
|
||||
description:
|
||||
"Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) BCH into wBCH tokens.",
|
||||
icon: "wrap",
|
||||
requirements: {
|
||||
participants: [
|
||||
{ role: 'service', slots: { min: 1, max: 1 } },
|
||||
{ role: 'unwrapper', slots: { min: 1, max: 1 } }
|
||||
],
|
||||
},
|
||||
|
||||
inputs: [{ input: "covenantInput", inputIndex: 0 }],
|
||||
outputs: [
|
||||
{ output: "covenantOutput", outputIndex: 0 },
|
||||
{ output: "wrappedTokensOutput", outputIndex: undefined },
|
||||
],
|
||||
transaction: 'unwrapTransaction',
|
||||
},
|
||||
},
|
||||
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
composable: true,
|
||||
},
|
||||
transactions: {
|
||||
wrapTransaction: {
|
||||
name: 'Wrapped BCH',
|
||||
description: 'Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) BCH into wBCH tokens.',
|
||||
icon: 'wrap',
|
||||
|
||||
unwrapTransaction: {
|
||||
name: "Unwrapped wBCH",
|
||||
description:
|
||||
"Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) wBCH tokens back into BCH.",
|
||||
icon: "unwrap",
|
||||
inputs: [
|
||||
{ input: 'covenantInput', inputIndex: 0 },
|
||||
],
|
||||
outputs: [
|
||||
{ output: 'covenantOutput', outputIndex: 0 },
|
||||
{ output: 'wrappedTokensOutput', outputIndex: undefined },
|
||||
],
|
||||
},
|
||||
|
||||
inputs: [{ input: "covenantInput", inputIndex: 0 }],
|
||||
outputs: [
|
||||
{ output: "covenantOutput", outputIndex: 0 },
|
||||
{ output: "unwrappedSatoshisOutput", outputIndex: undefined },
|
||||
],
|
||||
unwrapTransaction: {
|
||||
name: 'Unwrapped wBCH',
|
||||
description: 'Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) wBCH tokens back into BCH.',
|
||||
icon: 'unwrap',
|
||||
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
composable: true,
|
||||
},
|
||||
},
|
||||
inputs: [
|
||||
{ input: 'covenantInput', inputIndex: 0 },
|
||||
],
|
||||
outputs: [
|
||||
{ output: 'covenantOutput', outputIndex: 0 },
|
||||
{ output: 'unwrappedSatoshisOutput', outputIndex: undefined },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
outputs: {
|
||||
covenantOutput: {
|
||||
name: "wBCH Covenant",
|
||||
description: "Holds BCH and wBCH tokens that can be freely converted.",
|
||||
icon: "contract",
|
||||
inputs: {
|
||||
covenantInput: {
|
||||
name: 'wBCH Covenant',
|
||||
description: 'The covenant being updated.',
|
||||
icon: 'contract',
|
||||
|
||||
lockingScript: "wrapBCHLockingScript",
|
||||
},
|
||||
valueSatoshis: '$(poolSatoshis)',
|
||||
token: {
|
||||
category: '$(<wbchTokenCategory>)',
|
||||
amount: '$(poolTokens)',
|
||||
nft: null,
|
||||
},
|
||||
|
||||
wrappedTokensOutput: {
|
||||
name: "Wrapped wBCH",
|
||||
description:
|
||||
"Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.",
|
||||
icon: "receive",
|
||||
// NOTE: This should be named unlockingBytecode, as it refers to raw script and not an unlocking script entry.
|
||||
// TODO: Rename after: https://gitlab.com/GeneralProtocols/xo/templates/-/work_items/31
|
||||
unlockingScript: 'wrapBCHUnlockingBytecode',
|
||||
},
|
||||
},
|
||||
|
||||
valueSatoshis: "$(<amountToWrap>)",
|
||||
token: {
|
||||
category: "$(<wbchTokenCategory>)",
|
||||
amount: "$(<amountToWrap>)",
|
||||
nft: null,
|
||||
},
|
||||
outputs: {
|
||||
covenantOutput: {
|
||||
name: 'wBCH Covenant',
|
||||
description: 'Holds BCH and wBCH tokens that can be freely converted.',
|
||||
icon: 'contract',
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
lockingScript: 'wrapBCHLockingScript',
|
||||
|
||||
lockingScript: "$(<recipientLockingScript>)",
|
||||
},
|
||||
valueSatoshis: '$(covenantChangeSatoshis)',
|
||||
token: {
|
||||
category: '$(<wbchTokenCategory>)',
|
||||
amount: '$(covenantChangeTokens)',
|
||||
nft: null,
|
||||
},
|
||||
|
||||
unwrappedSatoshisOutput: {
|
||||
name: "Unwrapped BCH",
|
||||
description:
|
||||
"Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.",
|
||||
icon: "receive",
|
||||
roles: {
|
||||
// Indicate that users should not index this output.
|
||||
// TODO: Remove this todo after: https://gitlab.com/GeneralProtocols/xo/templates/-/work_items/32
|
||||
// wrapper: { relevant: false },
|
||||
// unwrapper: { relevant: false },
|
||||
|
||||
valueSatoshis: "$(<amountToUnwrap>)",
|
||||
token: null,
|
||||
// Define how the service provider can interact with this output.
|
||||
service: {
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
// Define wrap and unwrap actions that the service provider can initialize.
|
||||
actions: [
|
||||
{
|
||||
action: 'wrap',
|
||||
role: 'service',
|
||||
variables: [
|
||||
{ direction: '$(<"wrap">)' },
|
||||
{ poolSatoshis: '$(XO_OUTPUTVALUE)' },
|
||||
{ poolTokens: '$(XO_OUTPUTTOKENAMOUNT)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
action: 'unwrap',
|
||||
role: 'service',
|
||||
variables: [
|
||||
{ direction: '$(<"unwrap">)' },
|
||||
{ poolSatoshis: '$(XO_OUTPUTVALUE)' },
|
||||
{ poolTokens: '$(XO_OUTPUTTOKENAMOUNT)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
lockingScript: "$(<recipientLockingScript>)",
|
||||
},
|
||||
},
|
||||
// Mark balance as unavailable, since the service provider cannot spend from the contract.
|
||||
balance: {
|
||||
satoshis: 0n,
|
||||
fungibleTokens: 0n,
|
||||
},
|
||||
|
||||
inputs: {
|
||||
covenantInput: {
|
||||
name: "wBCH Covenant",
|
||||
description: "The covenant being updated.",
|
||||
icon: "contract",
|
||||
// Mark output as not selectable, since the service provider cannot spend from the contract.
|
||||
selectable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
unlockingScript: "unlockCovenant",
|
||||
},
|
||||
},
|
||||
wrappedTokensOutput: {
|
||||
name: 'Wrapped wBCH',
|
||||
description: 'Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.',
|
||||
icon: 'receive',
|
||||
|
||||
lockingScripts: {
|
||||
wrapBCHLockingScript: {
|
||||
name: "wBCH Covenant",
|
||||
description: "Holds BCH and wBCH tokens that can be freely converted.",
|
||||
icon: "contract",
|
||||
valueSatoshis: '<tokenDust>',
|
||||
token: {
|
||||
category: '$(<wbchTokenCategory>)',
|
||||
amount: '$(<amountToWrap>)',
|
||||
nft: null,
|
||||
},
|
||||
|
||||
lockingType: "p2sh",
|
||||
lockingBytecode: "wrapBCHLockingBytecode",
|
||||
lockingScript: '$(<recipientLockingScript>)',
|
||||
},
|
||||
|
||||
actions: [
|
||||
{ action: "wrap", role: "user" },
|
||||
{ action: "unwrap", role: "user" },
|
||||
],
|
||||
unwrappedSatoshisOutput: {
|
||||
name: 'Unwrapped BCH',
|
||||
description: 'Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.',
|
||||
icon: 'receive',
|
||||
|
||||
state: {
|
||||
variables: [],
|
||||
secrets: [],
|
||||
},
|
||||
balance: {
|
||||
satoshis: 0n,
|
||||
fungibleTokens: 0n,
|
||||
},
|
||||
selectable: false,
|
||||
},
|
||||
},
|
||||
valueSatoshis: '$(<amountToUnwrap>)',
|
||||
token: null,
|
||||
|
||||
scripts: {
|
||||
enforceCovenantPersists:
|
||||
"OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY",
|
||||
enforceTokenCategoryPreserved:
|
||||
"OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY",
|
||||
enforceValueTokenSumConserved:
|
||||
"OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY",
|
||||
lockingScript: '$(<recipientLockingScript>)',
|
||||
},
|
||||
},
|
||||
|
||||
// Direct script references — introspection opcodes must not use $(...) evaluations
|
||||
// because those are evaluated at compile time without transaction context.
|
||||
wrapBCHLockingBytecode:
|
||||
"enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved",
|
||||
unlockCovenant: "",
|
||||
},
|
||||
lockingScripts: {
|
||||
wrapBCHLockingScript: {
|
||||
name: 'wBCH Covenant',
|
||||
description: 'Holds BCH and wBCH tokens that can be freely converted.',
|
||||
icon: 'contract',
|
||||
|
||||
constants: {
|
||||
wbchTokenCategory: {
|
||||
name: "wBCH Token Category",
|
||||
description: "The official token category for Wrapped BCH.",
|
||||
type: "bytes",
|
||||
value: "ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3",
|
||||
},
|
||||
satoshisPerBCH: {
|
||||
name: "Satoshis per BCH",
|
||||
description: "Used to display amounts in BCH with decimals.",
|
||||
type: "integer",
|
||||
value: 100000000,
|
||||
},
|
||||
tokenDust: {
|
||||
name: "Token Dust Limit",
|
||||
description: "Minimal satoshis required for a token-bearing output.",
|
||||
type: "integer",
|
||||
value: 1000,
|
||||
},
|
||||
},
|
||||
lockingType: 'p2sh',
|
||||
lockingBytecode: 'wrapBCHLockingBytecode',
|
||||
|
||||
variables: {
|
||||
amountToWrap: {
|
||||
name: "Amount to Wrap",
|
||||
description: "How much BCH to convert to wBCH (in satoshis).",
|
||||
type: "integer",
|
||||
hint: "satoshis",
|
||||
},
|
||||
amountToUnwrap: {
|
||||
name: "Amount to Unwrap",
|
||||
description: "How much wBCH to convert back to BCH (in satoshis).",
|
||||
type: "integer",
|
||||
hint: "satoshis",
|
||||
},
|
||||
recipientLockingScript: {
|
||||
name: "Destination",
|
||||
description: "Where to receive your BCH or wBCH tokens.",
|
||||
type: "bytes",
|
||||
hint: "lockingScript",
|
||||
},
|
||||
},
|
||||
// All outputs on the covenent are covenant outputs.
|
||||
// TODO: Remove this todo after: https://gitlab.com/GeneralProtocols/xo/templates/-/work_items/33
|
||||
// defaultOutput: 'covenantOutput',
|
||||
|
||||
icons: [
|
||||
{ name: "wrap", hash: "0000000000000000000000" },
|
||||
{ name: "unwrap", hash: "0000000000000000000000" },
|
||||
{ name: "user", hash: "0000000000000000000000" },
|
||||
{ name: "contract", hash: "0000000000000000000000" },
|
||||
{ name: "receive", hash: "0000000000000000000000" },
|
||||
],
|
||||
// Indicate that users should not index this locking script.
|
||||
// TODO: Remove this todo after: https://gitlab.com/GeneralProtocols/xo/templates/-/work_items/32
|
||||
roles: {
|
||||
// wrapper: { relevant: false },
|
||||
// unwrapper: { relevant: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
scripts: {
|
||||
// Utility to check if we are doing a wrap or unwrap.
|
||||
checkIfWrapping: '<direction> <"wrap"> OP_EQUAL',
|
||||
|
||||
// Utilities to calculate the covenent change satoshis.
|
||||
wrappedChangeSatoshis: '<poolSatoshis> <amountToWrap> OP_ADD',
|
||||
unwrappedChangeSatoshis: '<poolSatoshis> <amountToUnwrap> OP_SUB',
|
||||
covenantChangeSatoshis: 'checkIfWrapping OP_IF wrappedChangeSatoshis OP_ELSE unwrappedChangeSatoshis OP_ENDIF',
|
||||
|
||||
// Utilities to calculate the covenent change tokens.
|
||||
wrappedChangeTokens: '<poolTokens> <amountToWrap> OP_SUB',
|
||||
unwrappedChangeTokens: '<poolTokens> <amountToUnwrap> OP_ADD',
|
||||
covenantChangeTokens: 'checkIfWrapping OP_IF wrappedChangeTokens OP_ELSE unwrappedChangeTokens OP_ENDIF',
|
||||
|
||||
// NOTE: This is the wrapped.cash covenant and so this bytecode cannot be updated.
|
||||
// NOTE: This covenant only ensure the security of its own funds, leaving user protection to be done in user space.
|
||||
enforceCovenantPersists: 'OP_INPUTINDEX OP_OUTPUTBYTECODE OP_INPUTINDEX OP_UTXOBYTECODE OP_EQUALVERIFY',
|
||||
enforceTokenCategoryPreserved: 'OP_INPUTINDEX OP_OUTPUTTOKENCATEGORY OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_EQUALVERIFY',
|
||||
enforceValueTokenSumConserved: 'OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_INPUTINDEX OP_UTXOVALUE OP_ADD OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_INPUTINDEX OP_OUTPUTVALUE OP_ADD OP_NUMEQUAL',
|
||||
|
||||
// The final lock and unlocking bytecodes.
|
||||
wrapBCHLockingBytecode: 'enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved',
|
||||
wrapBCHUnlockingBytecode: '<wrapBCHLockingBytecode>',
|
||||
},
|
||||
|
||||
constants: {
|
||||
wbchTokenCategory: {
|
||||
name: 'wBCH Token Category',
|
||||
description: 'The official token category for Wrapped BCH.',
|
||||
type: 'bytes',
|
||||
value: 'ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3',
|
||||
},
|
||||
satoshisPerBCH: {
|
||||
name: 'Satoshis per BCH',
|
||||
description: 'Used to display amounts in BCH with decimals.',
|
||||
type: 'integer',
|
||||
value: 100000000,
|
||||
},
|
||||
tokenDust: {
|
||||
name: 'Token Dust Limit',
|
||||
description: 'Minimal satoshis required for a token-bearing output.',
|
||||
type: 'integer',
|
||||
value: 1000,
|
||||
},
|
||||
},
|
||||
|
||||
variables: {
|
||||
|
||||
// Internal variables to be provided by the application when choosing a pool UTXO.
|
||||
direction: {
|
||||
name: 'Wrap or Unwrap',
|
||||
description: 'Internal variable used to determine if we are wrapping or unwrapping.',
|
||||
type: 'string',
|
||||
},
|
||||
poolSatoshis: {
|
||||
name: 'Initial Pool Satoshis',
|
||||
description: 'Internal variable used to determine how much satoshis to keep on the covenant.',
|
||||
type: 'integer',
|
||||
},
|
||||
poolTokens: {
|
||||
name: 'Initial Pool Tokens',
|
||||
description: 'Internal variable used to determine how much fungible tokens to keep on the covenant.',
|
||||
type: 'integer',
|
||||
},
|
||||
|
||||
// Public variables to be provided by the user.
|
||||
amountToWrap: {
|
||||
name: 'Amount to Wrap',
|
||||
description: 'How much BCH to convert to wBCH (in satoshis).',
|
||||
type: 'integer',
|
||||
hint: 'satoshis',
|
||||
},
|
||||
amountToUnwrap: {
|
||||
name: 'Amount to Unwrap',
|
||||
description: 'How much wBCH to convert back to BCH (in satoshis).',
|
||||
type: 'integer',
|
||||
hint: 'satoshis',
|
||||
},
|
||||
recipientLockingScript: {
|
||||
name: 'Destination',
|
||||
description: 'Where to receive your BCH or wBCH tokens.',
|
||||
type: 'bytes',
|
||||
},
|
||||
},
|
||||
|
||||
icons: [
|
||||
{ name: 'wrap', hash: '0000000000000000000000' },
|
||||
{ name: 'unwrap', hash: '0000000000000000000000' },
|
||||
{ name: 'user', hash: '0000000000000000000000' },
|
||||
{ name: 'contract', hash: '0000000000000000000000' },
|
||||
{ name: 'receive', hash: '0000000000000000000000' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ export function AppProvider({
|
||||
});
|
||||
|
||||
// Start the AppService (loads existing invitations)
|
||||
service.start();
|
||||
await service.start();
|
||||
|
||||
// Set the service and mark as initialized
|
||||
setAppService(service);
|
||||
|
||||
@@ -359,7 +359,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
|
||||
try {
|
||||
setStatus('Deleting template...');
|
||||
await appService.engine.DANGEROUS_deleteImportedTemplate(
|
||||
await appService.engine.archiveTemplate(
|
||||
templateToDelete.templateIdentifier,
|
||||
);
|
||||
setIsDeleteDialogOpen(false);
|
||||
|
||||
@@ -331,7 +331,6 @@ export function useActionWizard() {
|
||||
);
|
||||
const success = await invitationManager.addInputsAndOutputs(
|
||||
selectedUtxos,
|
||||
utxoSelection.changeAmount,
|
||||
);
|
||||
if (success) focus.resetToContent();
|
||||
return success;
|
||||
@@ -493,9 +492,12 @@ export function useActionWizard() {
|
||||
selectedUtxoIndex: utxoSelection.selectedUtxoIndex,
|
||||
setSelectedUtxoIndex: utxoSelection.setSelectedUtxoIndex,
|
||||
requiredAmount: utxoSelection.requiredAmount,
|
||||
fee: utxoSelection.fee,
|
||||
fee:
|
||||
invitationManager.feeAwareChange?.feeSatoshis ?? utxoSelection.fee,
|
||||
selectedAmount: utxoSelection.selectedAmount,
|
||||
changeAmount: utxoSelection.changeAmount,
|
||||
changeAmount:
|
||||
invitationManager.feeAwareChange?.changeAmountSatoshis ??
|
||||
utxoSelection.changeAmount,
|
||||
toggleUtxoSelection: utxoSelection.toggleSelection,
|
||||
selectAll: utxoSelection.selectAll,
|
||||
deselectAll: utxoSelection.deselectAll,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveProvidedLockingBytecodeHex,
|
||||
} from "../../../../utils/invitation-flow.js";
|
||||
import type { AppService } from "../../../../services/app.js";
|
||||
import type { FeeAwareChangeResult } from "../../../../services/invitation.js";
|
||||
|
||||
interface InvitationManagerDeps {
|
||||
appService: AppService;
|
||||
@@ -34,6 +35,8 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
|
||||
const [requirementsComplete, setRequirementsComplete] = useState(false);
|
||||
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [feeAwareChange, setFeeAwareChange] =
|
||||
useState<FeeAwareChangeResult | null>(null);
|
||||
|
||||
/** Re-check whether all invitation requirements are satisfied. */
|
||||
const refreshRequirements = useCallback(
|
||||
@@ -195,10 +198,7 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
|
||||
* @returns true on success, false on failure.
|
||||
*/
|
||||
const addInputsAndOutputs = useCallback(
|
||||
async (
|
||||
selectedUtxos: SelectableUTXO[],
|
||||
changeAmount: bigint,
|
||||
): Promise<boolean> => {
|
||||
async (selectedUtxos: SelectableUTXO[]): Promise<boolean> => {
|
||||
if (!invitationId || !appService) return false;
|
||||
|
||||
setIsProcessing(true);
|
||||
@@ -218,7 +218,7 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
|
||||
}));
|
||||
|
||||
await instance.addInputs(inputs);
|
||||
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
|
||||
setFeeAwareChange(await instance.addFeeAwareChange());
|
||||
await refreshRequirements(invitationId);
|
||||
setStatus("Inputs and outputs added");
|
||||
return true;
|
||||
@@ -283,6 +283,7 @@ export function useInvitationManager(deps: InvitationManagerDeps) {
|
||||
invitationId,
|
||||
requirementsComplete,
|
||||
hasSignedAndBroadcasted,
|
||||
feeAwareChange,
|
||||
isProcessing,
|
||||
setIsProcessing,
|
||||
refreshRequirements,
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
formatInvitationListItem,
|
||||
formatInvitationId,
|
||||
} from '../../../utils/invitation-utils.js';
|
||||
import type { ResolvedInvitationVariable } from '@xo-cash/engine';
|
||||
// import type { ResolvedInvitationVariable } from '@xo-cash/engine';
|
||||
import type { ResolvedInvitationVariable } from '../../../utils/resolve-invitation-data.js'
|
||||
|
||||
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
|
||||
import { compileCashAssemblyString } from '@xo-cash/engine';
|
||||
@@ -375,7 +376,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
setIsLoading(false)
|
||||
setStatus('Ready')
|
||||
}
|
||||
})
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
const copyId = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
@@ -480,8 +481,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const changeAmount = accumulated - requiredAmount - fee;
|
||||
|
||||
setStatus('Adding inputs...');
|
||||
await selectedInvitation.addInputs(
|
||||
selectedUtxos.map(u => ({
|
||||
@@ -490,20 +489,16 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}))
|
||||
);
|
||||
|
||||
if (changeAmount >= dust) {
|
||||
setStatus('Adding change output...');
|
||||
await selectedInvitation.addOutputs([{
|
||||
valueSatoshis: changeAmount,
|
||||
}]);
|
||||
}
|
||||
setStatus('Calculating miner fee and change...');
|
||||
const feeAwareChange = await selectedInvitation.addFeeAwareChange();
|
||||
|
||||
showInfo(
|
||||
`Requirements filled!\n\n` +
|
||||
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
||||
`• Total: ${formatSatoshis(accumulated)}\n` +
|
||||
`• Required: ${formatSatoshis(requiredAmount)}\n` +
|
||||
`• Fee: ${formatSatoshis(fee)}\n` +
|
||||
`• Change: ${formatSatoshis(changeAmount)}\n\n` +
|
||||
`• Fee: ${formatSatoshis(feeAwareChange.feeSatoshis)}\n` +
|
||||
`• Change: ${formatSatoshis(feeAwareChange.changeAmountSatoshis)}\n\n` +
|
||||
`Now use "Sign Transaction" to complete.`
|
||||
);
|
||||
setStatus('Ready');
|
||||
|
||||
@@ -31,9 +31,6 @@ import { hexToBin } from '@bitauth/libauth';
|
||||
/** Default fee estimate in satoshis. */
|
||||
const DEFAULT_FEE = 500n;
|
||||
|
||||
/** Dust threshold — outputs below this are unspendable. */
|
||||
const DUST_THRESHOLD = 546n;
|
||||
|
||||
/**
|
||||
* Resolve the fixed index of a flow step from `IMPORT_STEPS`.
|
||||
* We centralize this so step transitions do not rely on magic numbers.
|
||||
@@ -70,6 +67,7 @@ export function InvitationImportFlow({
|
||||
const [variableInputs, setVariableInputs] = useState<ImportVariableInput[]>([]);
|
||||
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
||||
const [changeAmount, setChangeAmount] = useState(0n);
|
||||
const [fee, setFee] = useState(DEFAULT_FEE);
|
||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||
|
||||
// ── Cancel handler ───────────────────────────────────────────────────────
|
||||
@@ -198,33 +196,30 @@ export function InvitationImportFlow({
|
||||
|
||||
/** InputsSelectStep completed — user selected UTXOs. */
|
||||
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
||||
setSelectedInputs(inputs);
|
||||
if (!invitation) return;
|
||||
|
||||
await invitation?.addInputs(inputs.map(input => ({
|
||||
outpointTransactionHash: hexToBin(input.outpointTransactionHash),
|
||||
outpointIndex: input.outpointIndex,
|
||||
})));
|
||||
try {
|
||||
setSelectedInputs(inputs);
|
||||
|
||||
// Compute totals from selected inputs
|
||||
const totalSelected = inputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||
await invitation.addInputs(inputs.map(input => ({
|
||||
outpointTransactionHash: hexToBin(input.outpointTransactionHash),
|
||||
outpointIndex: input.outpointIndex,
|
||||
})));
|
||||
|
||||
// Determine required amount from invitation variables
|
||||
const requiredSats = await invitation?.getSatsOut() ?? 0n;
|
||||
setRequiredAmount(requiredSats);
|
||||
const requiredSats = await invitation.getSatsOut();
|
||||
setRequiredAmount(requiredSats);
|
||||
|
||||
// Set the change amount for the review step
|
||||
const changeAmountSats = totalSelected - requiredSats - DEFAULT_FEE;
|
||||
setChangeAmount(changeAmountSats);
|
||||
const feeAwareChange = await invitation.addFeeAwareChange();
|
||||
setChangeAmount(feeAwareChange.changeAmountSatoshis);
|
||||
setFee(feeAwareChange.feeSatoshis);
|
||||
|
||||
// Add the change output if it exceeds the dust threshold
|
||||
if (changeAmountSats >= DUST_THRESHOLD) {
|
||||
await invitation?.addOutputs([{
|
||||
valueSatoshis: changeAmountSats,
|
||||
}]);
|
||||
setCurrentStep(REVIEW_STEP_INDEX); // → Review
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to add inputs and calculate change: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
setCurrentStep(REVIEW_STEP_INDEX); // → Review
|
||||
}, [invitation]);
|
||||
}, [invitation, showError]);
|
||||
|
||||
/** ReviewStep completed — invitation import is done. */
|
||||
const handleReviewComplete = useCallback(() => {
|
||||
@@ -332,6 +327,7 @@ export function InvitationImportFlow({
|
||||
selectedRole={selectedRole}
|
||||
selectedInputs={selectedInputs}
|
||||
changeAmount={changeAmount}
|
||||
fee={fee}
|
||||
requiredAmount={requiredAmount}
|
||||
appService={appService}
|
||||
onComplete={handleReviewComplete}
|
||||
|
||||
@@ -14,9 +14,6 @@ import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.j
|
||||
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
|
||||
|
||||
/** Default fee estimate in satoshis. */
|
||||
const DEFAULT_FEE = 500n;
|
||||
|
||||
/** Dust threshold — outputs below this are unspendable. */
|
||||
const DUST_THRESHOLD = 546n;
|
||||
|
||||
@@ -27,6 +24,7 @@ export function ReviewStep({
|
||||
selectedInputs,
|
||||
requiredAmount,
|
||||
changeAmount,
|
||||
fee,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
@@ -35,7 +33,6 @@ export function ReviewStep({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||
|
||||
const fee = DEFAULT_FEE;
|
||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||
|
||||
// Compute totals from selected inputs
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface ReviewStepProps {
|
||||
selectedRole: string;
|
||||
selectedInputs: SelectableUTXO[];
|
||||
changeAmount: bigint;
|
||||
fee: bigint;
|
||||
requiredAmount: bigint;
|
||||
appService: AppService;
|
||||
onComplete: () => void;
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Transforms a raw XO invitation into a flattened, template-enriched structure
|
||||
* suitable for UI display without manually resolving template references.
|
||||
*
|
||||
* The original invitation format is unchanged in storage and transport; this
|
||||
* function produces a read model that merges commit data with template metadata
|
||||
* (names, descriptions, icons, roles, etc.).
|
||||
*/
|
||||
|
||||
import { mergeInvitationCommits } from "@xo-cash/engine";
|
||||
import { binToHex } from "@bitauth/libauth";
|
||||
import type {
|
||||
XOInvitation,
|
||||
XOInvitationCommit,
|
||||
XOInvitationInput,
|
||||
XOInvitationOutput,
|
||||
XOInvitationVariable,
|
||||
XOInvitationVariableValue,
|
||||
XOTemplate,
|
||||
XOTemplateInput,
|
||||
XOTemplateOutput,
|
||||
XOTemplateVariable,
|
||||
} from "@xo-cash/types";
|
||||
|
||||
/**
|
||||
* A variable from invitation commits enriched with its template definition.
|
||||
*/
|
||||
export interface ResolvedInvitationVariable {
|
||||
entityIdentifier: string;
|
||||
variableIdentifier: string;
|
||||
roleIdentifier?: string;
|
||||
value: XOInvitationVariableValue;
|
||||
name?: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A transaction input from invitation commits enriched with its template definition.
|
||||
*/
|
||||
export type ResolvedInvitationInput = XOInvitationInput & {
|
||||
entityIdentifier: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
unlockingScript?: string;
|
||||
omitChangeAmounts?: XOTemplateInput["omitChangeAmounts"];
|
||||
};
|
||||
|
||||
/**
|
||||
* A transaction output from invitation commits enriched with its template definition.
|
||||
*/
|
||||
export type ResolvedInvitationOutput = XOInvitationOutput & {
|
||||
entityIdentifier: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
roles?: Record<
|
||||
string,
|
||||
{ name?: string; description?: string; icon?: string }
|
||||
>;
|
||||
lockingScript?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattened, template-enriched invitation data for UI consumption.
|
||||
*/
|
||||
export interface ResolvedInvitationData {
|
||||
invitationIdentifier: string;
|
||||
templateIdentifier: string;
|
||||
actionIdentifier: string;
|
||||
variables: ResolvedInvitationVariable[];
|
||||
inputs: ResolvedInvitationInput[];
|
||||
outputs: ResolvedInvitationOutput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks human-readable view fields from a template definition.
|
||||
*/
|
||||
export const pickTemplateViewMetadata = (definition?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}) => {
|
||||
if (!definition) return {};
|
||||
|
||||
// Only copy fields that are present so absent template metadata does not
|
||||
// overwrite committed values when this object is spread onto a commit row.
|
||||
return {
|
||||
...(definition.name !== undefined && { name: definition.name }),
|
||||
...(definition.description !== undefined && {
|
||||
description: definition.description,
|
||||
}),
|
||||
...(definition.icon !== undefined && { icon: definition.icon }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks variable metadata from a template variable definition.
|
||||
*/
|
||||
export const pickTemplateVariableMetadata = (
|
||||
definition?: XOTemplateVariable,
|
||||
) => {
|
||||
if (!definition) return {};
|
||||
|
||||
return {
|
||||
...pickTemplateViewMetadata(definition),
|
||||
...(definition.type !== undefined && { type: definition.type }),
|
||||
...(definition.hint !== undefined && { hint: definition.hint }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks input metadata from a template input definition.
|
||||
*/
|
||||
export const pickTemplateInputMetadata = (definition?: XOTemplateInput) => {
|
||||
if (!definition) return {};
|
||||
|
||||
return {
|
||||
...pickTemplateViewMetadata(definition),
|
||||
...(definition.unlockingScript !== undefined && {
|
||||
unlockingScript: definition.unlockingScript,
|
||||
}),
|
||||
...(definition.omitChangeAmounts !== undefined && {
|
||||
omitChangeAmounts: definition.omitChangeAmounts,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks output metadata from a template output definition.
|
||||
*
|
||||
* Committed output values (e.g. lockingBytecode) take precedence over template
|
||||
* defaults; display-oriented fields like name, description, and template
|
||||
* valueSatoshis expressions are layered on for UI rendering.
|
||||
*/
|
||||
export const pickTemplateOutputMetadata = (definition?: XOTemplateOutput) => {
|
||||
if (!definition) return {};
|
||||
|
||||
const roles = definition.roles
|
||||
? Object.fromEntries(
|
||||
Object.entries(definition.roles).map(([roleId, roleDefinition]) => [
|
||||
roleId,
|
||||
pickTemplateViewMetadata(roleDefinition),
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...pickTemplateViewMetadata(definition),
|
||||
...(roles !== undefined && Object.keys(roles).length > 0 && { roles }),
|
||||
...(definition.lockingScript !== undefined && {
|
||||
lockingScript: definition.lockingScript,
|
||||
}),
|
||||
// Keep CashAssembly expressions (e.g. "$(<totalSatoshis>)") for UI compilation;
|
||||
// committed bigint values on the output row take precedence when spread later.
|
||||
...(definition.valueSatoshis !== undefined && {
|
||||
valueSatoshis: definition.valueSatoshis,
|
||||
}),
|
||||
...(definition.token !== undefined && { token: definition.token }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Enriches a committed variable with its template definition.
|
||||
*/
|
||||
export const resolveVariable = (
|
||||
variable: XOInvitationVariable,
|
||||
entityIdentifier: string,
|
||||
template: XOTemplate,
|
||||
): ResolvedInvitationVariable => ({
|
||||
entityIdentifier,
|
||||
variableIdentifier: variable.variableIdentifier,
|
||||
...(variable.roleIdentifier !== undefined && {
|
||||
roleIdentifier: variable.roleIdentifier,
|
||||
}),
|
||||
value: variable.value,
|
||||
...pickTemplateVariableMetadata(
|
||||
template.variables?.[variable.variableIdentifier],
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Enriches a committed input with its template definition when an identifier is present.
|
||||
*/
|
||||
export const resolveInput = (
|
||||
input: XOInvitationInput,
|
||||
entityIdentifier: string,
|
||||
template: XOTemplate,
|
||||
): ResolvedInvitationInput => ({
|
||||
entityIdentifier,
|
||||
...input,
|
||||
...pickTemplateInputMetadata(
|
||||
input.inputIdentifier
|
||||
? template.inputs?.[input.inputIdentifier]
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Enriches a committed output with its template definition when an identifier is present.
|
||||
*
|
||||
* Template metadata is spread after commit fields so display expressions (e.g.
|
||||
* `valueSatoshis: "$(<totalSatoshis>)"`) layer on for the UI even when the merger
|
||||
* already resolved a bigint for transaction encoding.
|
||||
*/
|
||||
export const resolveOutput = (
|
||||
output: XOInvitationOutput,
|
||||
entityIdentifier: string,
|
||||
template: XOTemplate,
|
||||
): ResolvedInvitationOutput =>
|
||||
({
|
||||
entityIdentifier,
|
||||
...output,
|
||||
...pickTemplateOutputMetadata(
|
||||
output.outputIdentifier
|
||||
? template.outputs?.[output.outputIdentifier]
|
||||
: undefined,
|
||||
),
|
||||
// Template valueSatoshis may be a CashAssembly string while XOInvitationOutput
|
||||
// expects bigint — the read model intentionally allows both for display.
|
||||
}) as ResolvedInvitationOutput;
|
||||
|
||||
/**
|
||||
* Converts hex or binary invitation bytecode fields to hex strings for display.
|
||||
*/
|
||||
export const hexOrBinToHex = (value?: string | Uint8Array) => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return typeof value === "string" ? value : binToHex(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a merged input row for UI display (hex strings, no encoding placeholders).
|
||||
*
|
||||
* The engine merger returns libauth-ready binary fields and fills in encoding
|
||||
* defaults (empty unlocking bytecode, sequence 0) that are not useful in the TUI.
|
||||
*/
|
||||
export const normalizeMergedInputForDisplay = (input: XOInvitationInput) => {
|
||||
const normalized = { ...input };
|
||||
|
||||
if (input.outpointTransactionHash !== undefined) {
|
||||
normalized.outpointTransactionHash = hexOrBinToHex(
|
||||
input.outpointTransactionHash,
|
||||
) as XOInvitationInput["outpointTransactionHash"];
|
||||
}
|
||||
|
||||
if (input.unlockingBytecode !== undefined) {
|
||||
// Engine uses an empty Uint8Array as a placeholder until the input is signed.
|
||||
const isPlaceholder =
|
||||
input.unlockingBytecode instanceof Uint8Array &&
|
||||
input.unlockingBytecode.length === 0;
|
||||
|
||||
if (isPlaceholder) {
|
||||
delete normalized.unlockingBytecode;
|
||||
} else {
|
||||
normalized.unlockingBytecode = hexOrBinToHex(
|
||||
input.unlockingBytecode,
|
||||
) as XOInvitationInput["unlockingBytecode"];
|
||||
}
|
||||
}
|
||||
|
||||
// Default sequence from the merger is not meaningful for display.
|
||||
if (normalized.sequenceNumber === 0) {
|
||||
delete normalized.sequenceNumber;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a merged output row for UI display (hex strings).
|
||||
*/
|
||||
export const normalizeMergedOutputForDisplay = (output: XOInvitationOutput) => {
|
||||
const normalized = { ...output };
|
||||
|
||||
if (output.lockingBytecode !== undefined) {
|
||||
normalized.lockingBytecode = hexOrBinToHex(
|
||||
output.lockingBytecode,
|
||||
) as XOInvitationOutput["lockingBytecode"];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recovers `outputIdentifier` from the source commit because the merger strips it
|
||||
* after template resolution.
|
||||
*/
|
||||
export const findOutputIdentifierForMergedOutput = (
|
||||
commit: XOInvitationCommit | undefined,
|
||||
mergedOutput: XOInvitationOutput,
|
||||
) => {
|
||||
const outputs = commit?.data?.outputs ?? [];
|
||||
const mergedBytecodeHex = hexOrBinToHex(mergedOutput.lockingBytecode);
|
||||
|
||||
for (const commitOutput of outputs) {
|
||||
if (commitOutput.outputIdentifier === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const commitBytecodeHex = hexOrBinToHex(commitOutput.lockingBytecode);
|
||||
|
||||
// Match merged binary bytecode back to the committed row that carried the identifier.
|
||||
if (
|
||||
mergedBytecodeHex !== undefined &&
|
||||
commitBytecodeHex !== undefined &&
|
||||
mergedBytecodeHex === commitBytecodeHex
|
||||
) {
|
||||
return commitOutput.outputIdentifier;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back when the commit has a single identified output (common case).
|
||||
const outputsWithIdentifier = outputs.filter(
|
||||
(commitOutput) => commitOutput.outputIdentifier !== undefined,
|
||||
);
|
||||
|
||||
if (outputsWithIdentifier.length === 1) {
|
||||
return outputsWithIdentifier[0]?.outputIdentifier;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether two invitation variable rows refer to the same template variable slot.
|
||||
*/
|
||||
export const matchesInvitationVariable = (
|
||||
left: XOInvitationVariable,
|
||||
right: XOInvitationVariable,
|
||||
) =>
|
||||
left.variableIdentifier === right.variableIdentifier &&
|
||||
left.roleIdentifier === right.roleIdentifier;
|
||||
|
||||
/**
|
||||
* Finds the entity that authored a merged variable by scanning invitation commits.
|
||||
* Last matching commit in array order wins. Best-effort until the engine orders
|
||||
* commits internally or exposes source attribution on merged variables.
|
||||
*/
|
||||
export const findVariableEntityIdentifier = (
|
||||
variable: XOInvitationVariable,
|
||||
commits: XOInvitationCommit[],
|
||||
) => {
|
||||
let entityIdentifier = "";
|
||||
|
||||
// Merged variables do not carry sourceCommitIdentifier today; walk commits and
|
||||
// let the last array match win (ordering deferred to the engine merger).
|
||||
for (const commit of commits) {
|
||||
for (const commitVariable of commit.data?.variables ?? []) {
|
||||
if (matchesInvitationVariable(commitVariable, variable)) {
|
||||
entityIdentifier = commit.entityIdentifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entityIdentifier;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns template-enriched invitation data for UI display.
|
||||
*
|
||||
* Uses {@link mergeInvitationCommits} for inputs and outputs so `mergesWith`
|
||||
* extensions and transaction indices are resolved. Variables come from the merged
|
||||
* result and are enriched with template metadata. Commit ordering is delegated to
|
||||
* the engine merger.
|
||||
*/
|
||||
export const resolveCommitReferences = (
|
||||
invitation: XOInvitation,
|
||||
template: XOTemplate,
|
||||
): ResolvedInvitationData => {
|
||||
const commits = invitation.commits ?? [];
|
||||
const commitsMap = new Map(
|
||||
commits.map((commit) => [commit.commitIdentifier, commit]),
|
||||
);
|
||||
|
||||
// Merge rather than flatten so mergesWith input extensions and transactionIndex
|
||||
// ordering are handled by the engine (see signing flow in engine.append/sign).
|
||||
const merged = mergeInvitationCommits(
|
||||
invitation as Parameters<typeof mergeInvitationCommits>[0],
|
||||
template,
|
||||
);
|
||||
|
||||
if (merged === null) {
|
||||
return {
|
||||
invitationIdentifier: invitation.invitationIdentifier,
|
||||
templateIdentifier: invitation.templateIdentifier,
|
||||
actionIdentifier: invitation.actionIdentifier,
|
||||
variables: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
};
|
||||
}
|
||||
|
||||
const variables = merged.variables.map((variable) =>
|
||||
resolveVariable(
|
||||
variable,
|
||||
findVariableEntityIdentifier(variable, commits),
|
||||
template,
|
||||
),
|
||||
);
|
||||
|
||||
const inputs = merged.inputs.map((mergedInput) => {
|
||||
const entityIdentifier =
|
||||
commitsMap.get(mergedInput.sourceCommitIdentifier)?.entityIdentifier ??
|
||||
"";
|
||||
// Strip merger-only fields before normalization and template enrichment.
|
||||
const {
|
||||
sourceCommitIdentifier: _sourceCommitIdentifier,
|
||||
mergesWith: _mergesWith,
|
||||
...input
|
||||
} = mergedInput;
|
||||
|
||||
return resolveInput(
|
||||
normalizeMergedInputForDisplay(input),
|
||||
entityIdentifier,
|
||||
template,
|
||||
);
|
||||
});
|
||||
|
||||
const outputs = merged.outputs.map((mergedOutput) => {
|
||||
const commit = commitsMap.get(mergedOutput.sourceCommitIdentifier);
|
||||
const entityIdentifier = commit?.entityIdentifier ?? "";
|
||||
const {
|
||||
sourceCommitIdentifier: _sourceCommitIdentifier,
|
||||
mergesWith: _mergesWith,
|
||||
...output
|
||||
} = mergedOutput;
|
||||
const outputIdentifier = findOutputIdentifierForMergedOutput(
|
||||
commit,
|
||||
output,
|
||||
);
|
||||
// Re-attach outputIdentifier so pickTemplateOutputMetadata can resolve names/roles.
|
||||
const outputForDisplay = normalizeMergedOutputForDisplay(
|
||||
outputIdentifier !== undefined ? { ...output, outputIdentifier } : output,
|
||||
);
|
||||
|
||||
return resolveOutput(outputForDisplay, entityIdentifier, template);
|
||||
});
|
||||
|
||||
return {
|
||||
invitationIdentifier: invitation.invitationIdentifier,
|
||||
templateIdentifier: invitation.templateIdentifier,
|
||||
actionIdentifier: invitation.actionIdentifier,
|
||||
variables,
|
||||
inputs,
|
||||
outputs,
|
||||
};
|
||||
};
|
||||
+338
-71
@@ -1,7 +1,44 @@
|
||||
import type { XOInvitation } from "@xo-cash/types";
|
||||
import { EventEmitter } from "./event-emitter.js";
|
||||
import { SSESession, type SSEvent } from "@xo-cash/utils";
|
||||
import { hexToBin } from "@bitauth/libauth";
|
||||
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
|
||||
import { PrivateKey } from "@xo-cash/primitives";
|
||||
import type { XOInvitation, XOInvitationCommit } from "@xo-cash/types";
|
||||
import type { SSEvent } from "@xo-cash/utils";
|
||||
|
||||
import { EventEmitter } from "./event-emitter.js";
|
||||
import { SSEClient } from "./syncing/index.js";
|
||||
|
||||
type ResourceInstance = {
|
||||
blob: Uint8Array;
|
||||
publicKey: string;
|
||||
resourceId?: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type ResourceSnapshot = {
|
||||
resources: Record<string, { instances: ResourceInstance[] }>;
|
||||
};
|
||||
|
||||
type InstanceChanged = ResourceInstance & {
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
function logSyncServer(message: string): void {
|
||||
console.error(`[SyncServer] ${message}`);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export type SyncServerEventMap = {
|
||||
connected: void;
|
||||
disconnected: void;
|
||||
error: Error;
|
||||
message: {
|
||||
event: "invitation-updated";
|
||||
data: XOInvitation;
|
||||
};
|
||||
};
|
||||
|
||||
function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation {
|
||||
const { entityIdentifier: _entityIdentifier, ...sharedInvitation } =
|
||||
@@ -10,107 +47,337 @@ function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation {
|
||||
return sharedInvitation;
|
||||
}
|
||||
|
||||
export type SyncServerEventMap = {
|
||||
connected: void;
|
||||
disconnected: void;
|
||||
error: Error;
|
||||
message: SSEvent;
|
||||
};
|
||||
function decodeProtocolValue(value: unknown): unknown {
|
||||
// HTTP reads are already decoded by SSEClient.fromExtendedJson(). Preserve
|
||||
// those blobs rather than recursively converting Uint8Array indices into a
|
||||
// plain object.
|
||||
if (value instanceof Uint8Array) return value;
|
||||
|
||||
if (typeof value === "string") {
|
||||
const bigint = value.match(/^<bigint: (?<value>[+-]?[0-9]+)n>$/u);
|
||||
if (bigint) return BigInt(bigint.groups!.value!);
|
||||
|
||||
const bytes = value.match(/^<uint8array: (?<hex>[0-9a-f]*)>$/iu);
|
||||
if (bytes) return hexToBin(bytes.groups!.hex!);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) return value.map(decodeProtocolValue);
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
decodeProtocolValue(child),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeInvitation(blob: unknown): XOInvitation | undefined {
|
||||
if (!(blob instanceof Uint8Array)) return undefined;
|
||||
|
||||
try {
|
||||
return stripLocalInvitationMetadata(
|
||||
deserializeInvitation(new TextDecoder().decode(blob)),
|
||||
);
|
||||
} catch (error) {
|
||||
logSyncServer(`Failed to decode invitation blob: ${errorMessage(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitation-scoped adapter for the resource-oriented v2 sync client.
|
||||
*
|
||||
* Invitation services keep their existing invitation-centric API while this
|
||||
* class owns resource IDs, v2 envelopes, instance blobs, and commit merging.
|
||||
*/
|
||||
export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||
static async from(
|
||||
baseUrl: string,
|
||||
invitationIdentifier: string,
|
||||
privateKey: Uint8Array,
|
||||
): Promise<SyncServer> {
|
||||
const server = new SyncServer(baseUrl, invitationIdentifier);
|
||||
const server = new SyncServer(baseUrl, invitationIdentifier, privateKey);
|
||||
await server.connect();
|
||||
return server;
|
||||
}
|
||||
|
||||
private sse: SSESession | null = null;
|
||||
private readonly client: SSEClient;
|
||||
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
baseUrl: string,
|
||||
private readonly invitationIdentifier: string,
|
||||
privateKey: Uint8Array,
|
||||
) {
|
||||
super();
|
||||
|
||||
logSyncServer(
|
||||
`Created adapter for ${invitationIdentifier} using ${baseUrl}`,
|
||||
);
|
||||
|
||||
this.client = new SSEClient(
|
||||
baseUrl,
|
||||
new PrivateKey(new Uint8Array(privateKey)),
|
||||
{
|
||||
onMessage: (message) => this.handleMessage(message),
|
||||
onError: (error) => {
|
||||
logSyncServer(
|
||||
`Transport error for ${this.invitationIdentifier}: ${errorMessage(error)}`,
|
||||
);
|
||||
this.emit("error", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.sse) {
|
||||
await this.sse.connect();
|
||||
return;
|
||||
}
|
||||
logSyncServer(`Connecting to ${this.invitationIdentifier}`);
|
||||
|
||||
await this.createSSESession();
|
||||
try {
|
||||
await this.client.subscribe(this.invitationIdentifier);
|
||||
logSyncServer(`Connected to ${this.invitationIdentifier}`);
|
||||
this.emit("connected", undefined);
|
||||
} catch (error) {
|
||||
logSyncServer(
|
||||
`Connection failed for ${this.invitationIdentifier}: ${errorMessage(error)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.sse?.disconnect();
|
||||
this.sse = null;
|
||||
}
|
||||
// This adapter owns a dedicated client, so disconnecting avoids the
|
||||
// client's empty-subscription reconnect behavior.
|
||||
logSyncServer(`Disconnecting from ${this.invitationIdentifier}`);
|
||||
|
||||
private async createSSESession(): Promise<void> {
|
||||
const sse = await SSESession.create(
|
||||
`${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(this.invitationIdentifier)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
persistent: true,
|
||||
onRequest: async (request) => {
|
||||
const { body: _body, ...requestWithoutBody } = request;
|
||||
return requestWithoutBody;
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
},
|
||||
onDisconnected: () => {
|
||||
this.emit("disconnected", undefined);
|
||||
},
|
||||
onConnected: () => {
|
||||
this.emit("connected", undefined);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.sse = sse;
|
||||
sse.on("message", (event: SSEvent) => {
|
||||
this.emit("message", event);
|
||||
});
|
||||
try {
|
||||
await this.client.disconnect();
|
||||
logSyncServer(`Disconnected from ${this.invitationIdentifier}`);
|
||||
this.emit("disconnected", undefined);
|
||||
} catch (error) {
|
||||
logSyncServer(
|
||||
`Disconnect failed for ${this.invitationIdentifier}: ${errorMessage(error)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getInvitation(identifier: string): Promise<XOInvitation | undefined> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(identifier)}`,
|
||||
);
|
||||
this.assertIdentifier(identifier);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
||||
logSyncServer(`Reading invitation ${identifier}`);
|
||||
|
||||
try {
|
||||
const response = decodeProtocolValue(
|
||||
await this.client.read(this.invitationIdentifier),
|
||||
);
|
||||
const instances = this.instancesFromPayload(response);
|
||||
const invitation = this.mergeInstances(instances);
|
||||
|
||||
logSyncServer(
|
||||
`Read invitation ${identifier}: ${instances.length} instance(s), ${
|
||||
invitation
|
||||
? `${invitation.commits.length} commit(s) found`
|
||||
: "no invitation found"
|
||||
}`,
|
||||
);
|
||||
|
||||
return invitation;
|
||||
} catch (error) {
|
||||
logSyncServer(`Read failed for ${identifier}: ${errorMessage(error)}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const invitation = deserializeInvitation(await response.text());
|
||||
return stripLocalInvitationMetadata(invitation);
|
||||
}
|
||||
|
||||
async publishInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||
const response = await fetch(`${this.baseUrl}/invitations`, {
|
||||
method: "POST",
|
||||
body: serializeInvitation(stripLocalInvitationMetadata(invitation)),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
this.assertIdentifier(invitation.invitationIdentifier);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to publish invitation: ${response.statusText}`);
|
||||
const sharedInvitation = stripLocalInvitationMetadata(invitation);
|
||||
logSyncServer(
|
||||
`Publishing invitation ${this.invitationIdentifier} with ${sharedInvitation.commits.length} commit(s)`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.client.write(this.invitationIdentifier, sharedInvitation);
|
||||
logSyncServer(`Published invitation ${this.invitationIdentifier}`);
|
||||
} catch (error) {
|
||||
logSyncServer(
|
||||
`Publish failed for ${this.invitationIdentifier}: ${errorMessage(error)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data = deserializeInvitation(await response.text());
|
||||
return stripLocalInvitationMetadata(data);
|
||||
return sharedInvitation;
|
||||
}
|
||||
|
||||
private handleMessage(message: string): void {
|
||||
logSyncServer(
|
||||
`Received server message for ${this.invitationIdentifier} (${message.length} bytes)`,
|
||||
);
|
||||
|
||||
try {
|
||||
const payload = decodeProtocolValue(JSON.parse(message));
|
||||
|
||||
if (
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"error" in payload
|
||||
) {
|
||||
logSyncServer(
|
||||
`Server reported an error for ${this.invitationIdentifier}: ${String(payload.error)}`,
|
||||
);
|
||||
this.emit("error", new Error(String(payload.error)));
|
||||
return;
|
||||
}
|
||||
|
||||
const invitation = this.invitationFromSubscriptionPayload(payload);
|
||||
|
||||
if (!invitation) {
|
||||
logSyncServer(
|
||||
`Ignored server message for ${this.invitationIdentifier}: no matching invitation data`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logSyncServer(
|
||||
`Invitation update received for ${this.invitationIdentifier}: ${invitation.commits.length} commit(s)`,
|
||||
);
|
||||
|
||||
this.emit("message", {
|
||||
event: "invitation-updated",
|
||||
data: invitation,
|
||||
});
|
||||
} catch (error) {
|
||||
logSyncServer(
|
||||
`Failed to handle server message for ${this.invitationIdentifier}: ${errorMessage(error)}`,
|
||||
);
|
||||
this.emit(
|
||||
"error",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private invitationFromSubscriptionPayload(
|
||||
payload: unknown,
|
||||
): XOInvitation | undefined {
|
||||
const snapshotInstances = this.instancesFromPayload(payload);
|
||||
if (snapshotInstances.length > 0) {
|
||||
return this.mergeInstances(snapshotInstances);
|
||||
}
|
||||
|
||||
if (!this.isInstanceChanged(payload)) return undefined;
|
||||
if (payload.resourceId !== this.invitationIdentifier) return undefined;
|
||||
|
||||
return decodeInvitation(payload.blob);
|
||||
}
|
||||
|
||||
private instancesFromPayload(payload: unknown): ResourceInstance[] {
|
||||
// Sync server v2 returns /data/get as a flat array of resource instances.
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
.filter((instance): instance is ResourceInstance =>
|
||||
this.isResourceInstance(instance),
|
||||
)
|
||||
.filter(
|
||||
(instance) =>
|
||||
instance.resourceId === undefined ||
|
||||
instance.resourceId === this.invitationIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
// Continue accepting the older resource-snapshot envelope.
|
||||
if (!this.isResourceSnapshot(payload)) return [];
|
||||
|
||||
const instances = payload.resources[this.invitationIdentifier]?.instances;
|
||||
if (!Array.isArray(instances)) return [];
|
||||
|
||||
return instances.filter((instance): instance is ResourceInstance =>
|
||||
this.isResourceInstance(instance),
|
||||
);
|
||||
}
|
||||
|
||||
private mergeInstances(
|
||||
instances: ResourceInstance[],
|
||||
): XOInvitation | undefined {
|
||||
const decoded = instances
|
||||
.map((instance) => ({
|
||||
invitation: decodeInvitation(instance.blob),
|
||||
timestamp: instance.timestamp,
|
||||
}))
|
||||
.filter(
|
||||
(entry): entry is { invitation: XOInvitation; timestamp: number } =>
|
||||
entry.invitation?.invitationIdentifier === this.invitationIdentifier,
|
||||
);
|
||||
|
||||
if (decoded.length === 0) return undefined;
|
||||
|
||||
const latest = decoded.reduce((current, candidate) =>
|
||||
candidate.timestamp > current.timestamp ? candidate : current,
|
||||
).invitation;
|
||||
const commits = new Map<string, XOInvitationCommit>(
|
||||
latest.commits.map((commit) => [commit.commitIdentifier, commit]),
|
||||
);
|
||||
|
||||
for (const { invitation } of decoded) {
|
||||
if (
|
||||
invitation.templateIdentifier !== latest.templateIdentifier ||
|
||||
invitation.actionIdentifier !== latest.actionIdentifier
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const commit of invitation.commits) {
|
||||
commits.set(commit.commitIdentifier, commit);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...latest, commits: [...commits.values()] };
|
||||
}
|
||||
|
||||
private assertIdentifier(identifier: string): void {
|
||||
if (identifier !== this.invitationIdentifier) {
|
||||
throw new Error(
|
||||
`Sync client is scoped to invitation ${this.invitationIdentifier}, not ${identifier}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isResourceSnapshot(value: unknown): value is ResourceSnapshot {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"resources" in value &&
|
||||
typeof value.resources === "object" &&
|
||||
value.resources !== null
|
||||
);
|
||||
}
|
||||
|
||||
private isResourceInstance(value: unknown): value is ResourceInstance {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"blob" in value &&
|
||||
value.blob instanceof Uint8Array &&
|
||||
"publicKey" in value &&
|
||||
typeof value.publicKey === "string" &&
|
||||
"timestamp" in value &&
|
||||
typeof value.timestamp === "number" &&
|
||||
(!("resourceId" in value) || typeof value.resourceId === "string")
|
||||
);
|
||||
}
|
||||
|
||||
private isInstanceChanged(value: unknown): value is InstanceChanged {
|
||||
return (
|
||||
this.isResourceInstance(value) &&
|
||||
"resourceId" in value &&
|
||||
typeof value.resourceId === "string" &&
|
||||
value.resourceId.length > 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './types.js';
|
||||
export * from './shared/client.js';
|
||||
|
||||
export * from './sse/index.js';
|
||||
export * from './ws/index.js';
|
||||
@@ -0,0 +1,122 @@
|
||||
import { binToHex, flattenBinArray, secp256k1, sha256 } from "@bitauth/libauth";
|
||||
|
||||
import { toExtendedJson } from "@xo-cash/utils";
|
||||
import type { WriteRequest } from "../types.js";
|
||||
|
||||
export type AuthenticatedRequestHeaders = {
|
||||
'X-Public-Key': string;
|
||||
'X-Timestamp': string;
|
||||
'X-Signature': string;
|
||||
};
|
||||
|
||||
export type SignedPayload = { publicKey: string; signature: string };
|
||||
|
||||
export abstract class SyncClient {
|
||||
|
||||
abstract connect(): Promise<void>;
|
||||
abstract disconnect(): Promise<void>;
|
||||
abstract write(resourceId: string, value: Record<string, unknown>): Promise<unknown>;
|
||||
abstract read(resourceId: string): Promise<unknown>;
|
||||
abstract subscribe(resourceId: string): Promise<void>;
|
||||
abstract unsubscribe(resourceId: string): Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Derives the resource private key from the private key and resource ID
|
||||
* @param privateKey - The private key to derive the resource private key from
|
||||
* @param resourceId - The resource ID to derive the resource private key from
|
||||
* @returns The resource private key
|
||||
*/
|
||||
static deriveResourcePrivateKey(privateKey: Uint8Array, resourceId: string): Uint8Array {
|
||||
// Convert the resource ID to bytes
|
||||
const resourceIdBytes = new TextEncoder().encode(resourceId);
|
||||
|
||||
// Hash the private key and resource ID
|
||||
return sha256.hash(flattenBinArray([ privateKey, resourceIdBytes ]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a payload with the private key and returns the public key and signature
|
||||
* @param privateKey - The private key to sign the payload with
|
||||
* @param payload - The payload to sign
|
||||
* @returns The public key and signature
|
||||
*/
|
||||
static signPayload(privateKey: Uint8Array, payload: string): SignedPayload {
|
||||
// Convert the payload to Binary and hash it
|
||||
const payloadHash = sha256.hash(new TextEncoder().encode(payload));
|
||||
|
||||
// Derive the public key
|
||||
const publicKey = secp256k1.derivePublicKeyCompressed(privateKey);
|
||||
|
||||
// If the public key is a string, throw an error
|
||||
if (typeof publicKey === 'string') {
|
||||
throw new Error('Failed to derive public key');
|
||||
}
|
||||
|
||||
// Sign the payload
|
||||
const signature = secp256k1.signMessageHashDER(privateKey, payloadHash);
|
||||
|
||||
// If the signature is a string, throw an error
|
||||
if (typeof signature === 'string') {
|
||||
throw new Error('Failed to sign message');
|
||||
}
|
||||
|
||||
return {
|
||||
publicKey: binToHex(publicKey),
|
||||
signature: binToHex(signature),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a write request with the private key and returns the write request
|
||||
* @param privateKey - The private key to sign the write request with
|
||||
* @param resourceId - The resource ID to write to
|
||||
* @param value - The value to write
|
||||
* @returns The write request
|
||||
*/
|
||||
static signWriteRequest(privateKey: Uint8Array, resourceId: string, value: Uint8Array): WriteRequest {
|
||||
// Derive the resource private key
|
||||
const derivedKey = this.deriveResourcePrivateKey(privateKey, resourceId);
|
||||
|
||||
// Create the payload
|
||||
const timestamp = Date.now();
|
||||
const payload = `${timestamp}:${resourceId}:${toExtendedJson(value)}`;
|
||||
|
||||
// Sign the payload
|
||||
const { publicKey, signature } = this.signPayload(derivedKey, payload);
|
||||
|
||||
return {
|
||||
id: resourceId,
|
||||
value,
|
||||
publicKey,
|
||||
timestamp,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a request with the private key and returns the public key, timestamp, and signature
|
||||
* @param privateKey - The private key to sign the request with
|
||||
* @param path - The path of the request
|
||||
* @param body - The body of the request
|
||||
* @returns The public key, timestamp, and signature
|
||||
*/
|
||||
static signRequest(privateKey: Uint8Array, path: string, body: unknown): AuthenticatedRequestHeaders {
|
||||
// Create the payload
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Convert the body to a string if it isn't already a string
|
||||
const bodyStr = typeof body === 'string' ? body : toExtendedJson(body);
|
||||
|
||||
// Create the payload as `Timestamp:Path:Body`
|
||||
const payload = `${timestamp}:${path}:${bodyStr}`;
|
||||
|
||||
// Sign the payload
|
||||
const { publicKey, signature } = this.signPayload(privateKey, payload);
|
||||
|
||||
return {
|
||||
'X-Public-Key': publicKey,
|
||||
'X-Timestamp': timestamp.toString(),
|
||||
'X-Signature': signature,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { SSESession, toExtendedJson, fromExtendedJson } from '@xo-cash/utils';
|
||||
import { PrivateKey } from '@xo-cash/primitives'
|
||||
|
||||
import { SyncClient } from '../shared/client.js';
|
||||
|
||||
export type SSEClientOptions = {
|
||||
onMessage: (message: string) => void;
|
||||
onError: (error: Error) => void;
|
||||
};
|
||||
|
||||
export class SSEClient extends SyncClient {
|
||||
private readonly url: string;
|
||||
public sseSession: SSESession;
|
||||
private readonly subscriptions: Set<string> = new Set();
|
||||
private readonly messageListeners = new Set<(message: string) => void>();
|
||||
private readonly errorListeners = new Set<(error: Error) => void>();
|
||||
|
||||
private privateKey: PrivateKey;
|
||||
private textEncoder = new TextEncoder();
|
||||
|
||||
private get subscriptionUrl() {
|
||||
return `${this.url}/data/subscribe`;
|
||||
}
|
||||
|
||||
constructor(url: string, privateKey: PrivateKey, options: Partial<SSEClientOptions> = {}) {
|
||||
super();
|
||||
|
||||
this.url = url;
|
||||
this.privateKey = privateKey;
|
||||
this.sseSession = new SSESession(`${this.url}/data/subscribe`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ resourceId: [ ...this.subscriptions ] }),
|
||||
});
|
||||
this.messageListeners.add(options.onMessage ?? (() => {}));
|
||||
this.errorListeners.add(options.onError ?? (() => {}));
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.sseSession.connect();
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
await this.sseSession.disconnect();
|
||||
}
|
||||
|
||||
async reconnect() {
|
||||
// Disconnect the current session
|
||||
await this.sseSession.disconnect();
|
||||
this.sseSession.off('message');
|
||||
this.sseSession.off('error');
|
||||
|
||||
// Build the request body
|
||||
const body = toExtendedJson({ resourceId: [ ...this.subscriptions ] });
|
||||
|
||||
// Re-create the session with the additional resource ID in the subscription list
|
||||
this.sseSession = new SSESession(this.subscriptionUrl, {
|
||||
onRequest: async (request) => {
|
||||
// Get the path from the URL
|
||||
const url = new URL(this.subscriptionUrl);
|
||||
const path = url.pathname;
|
||||
|
||||
// Sign the request body
|
||||
const authHeaders = SyncClient.signRequest(this.privateKey.toBytes(), path, request.body);
|
||||
|
||||
// Initialize the request headers if they don't exist
|
||||
request.headers ??= {};
|
||||
|
||||
// Add the authentication headers to the request
|
||||
request.headers = {
|
||||
...request.headers,
|
||||
...authHeaders,
|
||||
}
|
||||
|
||||
// Return the request with the authentication headers
|
||||
return request;
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
// Create a listener to re-emit the messages
|
||||
this.sseSession.on('message', (message) => {
|
||||
for (const listener of this.messageListeners) {
|
||||
listener(message.data);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a listener to re-emit the errors
|
||||
this.sseSession.on('error', (error) => {
|
||||
for (const listener of this.errorListeners) {
|
||||
listener(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect to the server
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
// Send a POST /data/write request to the server
|
||||
async write(resourceId: string, value: Record<string, unknown>) {
|
||||
const url = `${this.url}/data/write`;
|
||||
|
||||
const valueStr = toExtendedJson(value);
|
||||
const valueBytes = this.textEncoder.encode(valueStr);
|
||||
const resource = SyncClient.signWriteRequest(this.privateKey.toBytes(), resourceId, valueBytes);
|
||||
|
||||
const body = toExtendedJson({ resources: [resource] });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
|
||||
// Headers arent required unless we have payment service running
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...SyncClient.signRequest(this.privateKey.toBytes(), '/data/write', body),
|
||||
},
|
||||
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(`Failed to write resource [${resourceId}] (${res.statusText}): ${JSON.stringify(error)}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Send a POST /data/read request to the server
|
||||
async read(resourceId: string) {
|
||||
const url = `${this.url}/data/get`;
|
||||
const body = toExtendedJson({ resourceId: [ resourceId ] });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...SyncClient.signRequest(this.privateKey.toBytes(), '/data/get', body),
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to read resource: ${res.statusText}`);
|
||||
}
|
||||
|
||||
return fromExtendedJson(await res.text());
|
||||
}
|
||||
|
||||
// Destroy the current SSE Session, then re-create it with the additional resource ID in the subscription list
|
||||
async subscribe(resourceId: string) {
|
||||
this.subscriptions.add(resourceId);
|
||||
|
||||
await this.reconnect();
|
||||
}
|
||||
|
||||
// Destroy the current SSE Session, then re-create it with the additional resource ID in the subscription list
|
||||
async unsubscribe(resourceId: string) {
|
||||
this.subscriptions.delete(resourceId);
|
||||
|
||||
await this.reconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './client.js';
|
||||
@@ -0,0 +1,11 @@
|
||||
export type WriteRequest = {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
timestamp: number;
|
||||
|
||||
/**
|
||||
* sha256(timestamp + id + toExtendedJson(value))
|
||||
*/
|
||||
signature: string;
|
||||
value: unknown;
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
import { fromExtendedJson, toExtendedJson } from '@xo-cash/utils';
|
||||
import type { PrivateKey } from '@xo-cash/primitives';
|
||||
|
||||
import { WsMessageSchema, WsSuccessResponseSchema, WsErrorResponseSchema, type WsMessage } from './types.js';
|
||||
import { SyncClient } from '../shared/client.js';
|
||||
|
||||
/** A request sent over the sync server's WebSocket connection. */
|
||||
type WsRequest = {
|
||||
id?: string;
|
||||
path: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (body: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
export type WsClientOptions = {
|
||||
/** Called for server-pushed events and uncorrelated server errors. */
|
||||
onMessage: (message: WsMessage) => void;
|
||||
|
||||
/** Called for transport errors and malformed server messages. */
|
||||
onError: (error: Error) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal WebSocket client for the XO sync server.
|
||||
*
|
||||
* This class intentionally owns only the basic WebSocket protocol:
|
||||
*
|
||||
* - one connection is opened at `/ws`;
|
||||
* - request IDs correlate concurrent read/write/unsubscribe calls;
|
||||
* - subscriptions stay on that connection and receive pushed events; and
|
||||
* - a dropped connection fails pending requests but is not reconnected.
|
||||
*
|
||||
* A production client would normally wrap this class with retry, heartbeat,
|
||||
* resubscription, and request-timeout behavior. Those concerns are omitted
|
||||
* here to keep the transport demo easy to follow.
|
||||
*/
|
||||
export class WsClient extends SyncClient {
|
||||
private readonly url: string;
|
||||
private readonly privateKey: PrivateKey;
|
||||
private socket: WebSocket | undefined;
|
||||
private readonly pendingRequests = new Map<string, PendingRequest>();
|
||||
private readonly messageListeners = new Set<(message: WsMessage) => void>();
|
||||
private readonly errorListeners = new Set<(error: Error) => void>();
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
privateKey: PrivateKey,
|
||||
options: Partial<WsClientOptions> = {},
|
||||
) {
|
||||
super();
|
||||
|
||||
this.url = url;
|
||||
this.privateKey = privateKey;
|
||||
this.messageListeners.add(options.onMessage ?? (() => {}));
|
||||
this.errorListeners.add(options.onError ?? (() => {}));
|
||||
}
|
||||
|
||||
/** Open the socket and resolve once the WebSocket handshake completes. */
|
||||
async connect(): Promise<void> {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = new WebSocket(WsClient.httpToWsUrl(this.url));
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = () => resolve();
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
this.handleMessage(String(event.data));
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
const error = new Error('WebSocket connection failed');
|
||||
this.emitError(error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (this.socket === socket) {
|
||||
this.socket = undefined;
|
||||
}
|
||||
|
||||
this.rejectPendingRequests(new Error('WebSocket connection closed'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the socket.
|
||||
*
|
||||
* Closing a WebSocket automatically removes all of its server-side topics,
|
||||
* so disconnect does not need to send an unsubscribe request first.
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
const socket = this.socket;
|
||||
this.socket = undefined;
|
||||
|
||||
this.rejectPendingRequests(new Error('WebSocket client disconnected'));
|
||||
|
||||
if (!socket || socket.readyState === WebSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for `close` so callers know the underlying connection is gone.
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.onclose = () => resolve();
|
||||
socket.close();
|
||||
});
|
||||
}
|
||||
|
||||
/** Read every stored instance of one resource. */
|
||||
async read(resourceId: string): Promise<unknown> {
|
||||
return this.request('/data/get', { resourceId: [resourceId] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign and write one resource, using the same wire format as the SSE client.
|
||||
*/
|
||||
async write(resourceId: string, value: Record<string, unknown>): Promise<unknown> {
|
||||
const valueBytes = new TextEncoder().encode(toExtendedJson(value));
|
||||
const resource = SyncClient.signWriteRequest(this.privateKey.toBytes(), resourceId, valueBytes);
|
||||
|
||||
return this.request('/data/write', { resources: [resource] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one resource topic on the current connection.
|
||||
*
|
||||
* The subscribe route is intentionally long-running and therefore does not
|
||||
* send an acknowledgement. This method resolves after the request frame has
|
||||
* been handed to the socket; future updates arrive through `onMessage`.
|
||||
*/
|
||||
async subscribe(resourceId: string): Promise<void> {
|
||||
this.send({
|
||||
path: '/data/subscribe',
|
||||
body: { resourceId: [resourceId] },
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove one resource topic without closing the shared connection. */
|
||||
async unsubscribe(resourceId: string): Promise<unknown> {
|
||||
return this.request('/data/unsubscribe', { resourceId: [resourceId] });
|
||||
}
|
||||
|
||||
/** Send a request and wait for the response carrying the same ID. */
|
||||
private async request(path: string, body?: unknown): Promise<unknown> {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
const response = new Promise<unknown>((resolve, reject) => {
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
});
|
||||
|
||||
// Sign the request body
|
||||
const headers = SyncClient.signRequest(this.privateKey.toBytes(), path, toExtendedJson(body));
|
||||
|
||||
try {
|
||||
this.send({ id, path, body, headers });
|
||||
} catch (error) {
|
||||
// Avoid leaving a promise in the map when the socket was not open.
|
||||
this.pendingRequests.delete(id);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Encode one complete request envelope using the shared Extended JSON codec. */
|
||||
private send(request: WsRequest): void {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('WebSocket is not connected');
|
||||
}
|
||||
|
||||
this.socket.send(toExtendedJson(request));
|
||||
}
|
||||
|
||||
/** Decode and route one frame received from the sync server. */
|
||||
private handleMessage(raw: string): void {
|
||||
const { success, data: message } = WsMessageSchema.safeParse(fromExtendedJson(raw));
|
||||
if (!success) {
|
||||
this.emitError(new Error('Invalid WebSocket message'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only response/error messages with an outstanding ID are RPC replies.
|
||||
// Application events are left for message listeners, even if they have IDs.
|
||||
const pending = message.id ? this.pendingRequests.get(message.id) : undefined;
|
||||
|
||||
// If the message has no outstanding ID, it is a subscription event so we will just notify the listeners
|
||||
if (!pending) {
|
||||
for (const listener of this.messageListeners) {
|
||||
listener(message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the pending request
|
||||
this.pendingRequests.delete(message.id!);
|
||||
|
||||
// If the message is an error, reject the pending request
|
||||
if (message.type === 'error') {
|
||||
const error = WsErrorResponseSchema.parse(message);
|
||||
pending.reject(new Error(`${error.error} (${error.statusCode})`));
|
||||
return;
|
||||
}
|
||||
|
||||
// If the message is a response, resolve the pending request
|
||||
if (message.type === 'response') {
|
||||
const response = WsSuccessResponseSchema.parse(message);
|
||||
|
||||
// If the response is not successful, reject the pending request
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
pending.reject(new Error(`Request failed (${response.statusCode})`));
|
||||
return;
|
||||
}
|
||||
|
||||
// If the response is successful, resolve the pending request
|
||||
pending.resolve(response.body);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to emit an error to all error listeners
|
||||
private emitError(error: Error): void {
|
||||
for (const listener of this.errorListeners) {
|
||||
listener(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for when the client disconnects
|
||||
private rejectPendingRequests(error: Error): void {
|
||||
for (const pending of this.pendingRequests.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
// Helper to convert an HTTP URL to a WebSocket URL
|
||||
static httpToWsUrl(httpUrl: string): string {
|
||||
const url = new URL(httpUrl);
|
||||
|
||||
if (url.protocol === 'http:') {
|
||||
url.protocol = 'ws:';
|
||||
} else if (url.protocol === 'https:') {
|
||||
url.protocol = 'wss:';
|
||||
} else if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
||||
throw new Error(`Unsupported sync server protocol: ${url.protocol}`);
|
||||
}
|
||||
|
||||
// The constructor accepts either the server root or the complete `/ws` URL.
|
||||
const pathname = url.pathname.replace(/\/+$/, '');
|
||||
url.pathname = pathname.endsWith('/ws') ? pathname : `${pathname}/ws`;
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './client.js';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** A normal, correlated reply to a WebSocket request. */
|
||||
export const WsSuccessResponseSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
type: z.literal('response'),
|
||||
statusCode: z.number(),
|
||||
body: z.unknown(),
|
||||
});
|
||||
export type WsSuccessResponse = z.infer<typeof WsSuccessResponseSchema>;
|
||||
|
||||
/** A public error returned by the server for one request. */
|
||||
export const WsErrorResponseSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
type: z.literal('error'),
|
||||
statusCode: z.number(),
|
||||
error: z.string(),
|
||||
details: z.unknown().optional(),
|
||||
});
|
||||
export type WsErrorResponse = z.infer<typeof WsErrorResponseSchema>;
|
||||
|
||||
export const WsResponseSchema = z.discriminatedUnion('type', [WsSuccessResponseSchema, WsErrorResponseSchema]);
|
||||
export type WsResponse = z.infer<typeof WsResponseSchema>;
|
||||
|
||||
/**
|
||||
* A server-pushed application event.
|
||||
*
|
||||
* Sync resource changes currently use the type `instance-changed`, but the
|
||||
* client deliberately leaves `type` open so this demo does not need updating
|
||||
* whenever the server adds another event.
|
||||
*/
|
||||
export const WsEventSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
type: z.string(),
|
||||
data: z.unknown(),
|
||||
});
|
||||
export type WsEvent = z.infer<typeof WsEventSchema>;
|
||||
|
||||
export const WsMessageSchema = z.union([WsResponseSchema, WsEventSchema]);
|
||||
export type WsMessage = z.infer<typeof WsMessageSchema>;
|
||||
@@ -180,5 +180,13 @@ export const createMockAppService = async (engine: Engine) => {
|
||||
invitationStoragePath: "test-invitations.db",
|
||||
};
|
||||
|
||||
return new AppService(engine, storage, config, mockElectrum, rates, settings);
|
||||
return new AppService(
|
||||
engine,
|
||||
storage,
|
||||
config,
|
||||
mockElectrum,
|
||||
rates,
|
||||
settings,
|
||||
new Uint8Array(32).fill(1),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { binToHex } from "@bitauth/libauth";
|
||||
import { serializeInvitation } from "@xo-cash/engine";
|
||||
import type { XOInvitation, XOInvitationCommit } from "@xo-cash/types";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const client = vi.hoisted(() => ({
|
||||
disconnect: vi.fn<() => Promise<void>>(),
|
||||
onError: undefined as ((error: Error) => void) | undefined,
|
||||
onMessage: undefined as ((message: string) => void) | undefined,
|
||||
read: vi.fn<(resourceId: string) => Promise<unknown>>(),
|
||||
subscribe: vi.fn<(resourceId: string) => Promise<void>>(),
|
||||
write:
|
||||
vi.fn<
|
||||
(resourceId: string, value: Record<string, unknown>) => Promise<unknown>
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/utils/syncing/index.js", () => ({
|
||||
SSEClient: class {
|
||||
constructor(
|
||||
_url: string,
|
||||
_privateKey: unknown,
|
||||
options: {
|
||||
onError?: (error: Error) => void;
|
||||
onMessage?: (message: string) => void;
|
||||
},
|
||||
) {
|
||||
client.onError = options.onError;
|
||||
client.onMessage = options.onMessage;
|
||||
}
|
||||
|
||||
disconnect = client.disconnect;
|
||||
read = client.read;
|
||||
subscribe = client.subscribe;
|
||||
write = client.write;
|
||||
},
|
||||
}));
|
||||
|
||||
import { SyncServer } from "../../src/utils/sync-server.js";
|
||||
|
||||
const invitationIdentifier = "01".repeat(16);
|
||||
const templateIdentifier = "02".repeat(32);
|
||||
|
||||
function makeCommit(commitIdentifier: string): XOInvitationCommit {
|
||||
return {
|
||||
commitIdentifier,
|
||||
createdAtTimestamp: 1,
|
||||
data: {},
|
||||
} as XOInvitationCommit;
|
||||
}
|
||||
|
||||
function makeInvitation(
|
||||
commits: XOInvitationCommit[],
|
||||
createdAtTimestamp = 1,
|
||||
): XOInvitation {
|
||||
return {
|
||||
actionIdentifier: "send",
|
||||
commits,
|
||||
createdAtTimestamp,
|
||||
invitationIdentifier,
|
||||
templateIdentifier,
|
||||
};
|
||||
}
|
||||
|
||||
function invitationBlob(invitation: XOInvitation): Uint8Array {
|
||||
return new TextEncoder().encode(serializeInvitation(invitation));
|
||||
}
|
||||
|
||||
function encodeProtocolBlob(invitation: XOInvitation): string {
|
||||
return `<Uint8Array: ${binToHex(invitationBlob(invitation))}>`;
|
||||
}
|
||||
|
||||
describe("SyncServer v2 adapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
client.onError = undefined;
|
||||
client.onMessage = undefined;
|
||||
client.disconnect.mockResolvedValue();
|
||||
client.subscribe.mockResolvedValue();
|
||||
client.write.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("scopes connection and writes to one invitation resource", async () => {
|
||||
const sync = new SyncServer(
|
||||
"https://v2.sync.xo.harvmaster.com",
|
||||
invitationIdentifier,
|
||||
new Uint8Array(32).fill(1),
|
||||
);
|
||||
const invitation = makeInvitation([makeCommit("commit-a")]);
|
||||
|
||||
await sync.connect();
|
||||
await sync.publishInvitation(invitation);
|
||||
await sync.disconnect();
|
||||
|
||||
expect(client.subscribe).toHaveBeenCalledWith(invitationIdentifier);
|
||||
expect(client.write).toHaveBeenCalledWith(invitationIdentifier, invitation);
|
||||
expect(client.disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("merges signer instances returned by a v2 resource read", async () => {
|
||||
const older = makeInvitation([makeCommit("commit-a")], 1);
|
||||
const newer = makeInvitation([makeCommit("commit-b")], 2);
|
||||
client.read.mockResolvedValue([
|
||||
{
|
||||
blob: invitationBlob(older),
|
||||
publicKey: "02aa",
|
||||
resourceId: invitationIdentifier,
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
blob: invitationBlob(newer),
|
||||
publicKey: "02bb",
|
||||
resourceId: invitationIdentifier,
|
||||
timestamp: 2,
|
||||
},
|
||||
]);
|
||||
const sync = new SyncServer(
|
||||
"https://v2.sync.xo.harvmaster.com",
|
||||
invitationIdentifier,
|
||||
new Uint8Array(32).fill(1),
|
||||
);
|
||||
|
||||
const invitation = await sync.getInvitation(invitationIdentifier);
|
||||
|
||||
expect(
|
||||
invitation?.commits.map((commit) => commit.commitIdentifier).sort(),
|
||||
).toEqual(["commit-a", "commit-b"]);
|
||||
expect(invitation?.createdAtTimestamp).toBe(2);
|
||||
});
|
||||
|
||||
it("translates live instance changes to the existing invitation event", () => {
|
||||
const updated = makeInvitation([makeCommit("commit-live")]);
|
||||
const sync = new SyncServer(
|
||||
"https://v2.sync.xo.harvmaster.com",
|
||||
invitationIdentifier,
|
||||
new Uint8Array(32).fill(1),
|
||||
);
|
||||
const messages: Array<{ event?: string; data: string }> = [];
|
||||
sync.on("message", (message) => messages.push(message));
|
||||
|
||||
client.onMessage?.(
|
||||
JSON.stringify({
|
||||
blob: encodeProtocolBlob(updated),
|
||||
publicKey: "02aa",
|
||||
resourceId: invitationIdentifier,
|
||||
timestamp: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.event).toBe("invitation-updated");
|
||||
expect(messages[0]?.data).toBe(serializeInvitation(updated));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user