Format with prettier. Use screen mode for invitation import - dialog mode is broken.
This commit is contained in:
@@ -1,28 +1,28 @@
|
||||
import {
|
||||
import {
|
||||
Engine,
|
||||
type XOEngineOptions,
|
||||
// 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';
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
} from "@xo-cash/engine";
|
||||
import type { XOInvitation } from "@xo-cash/types";
|
||||
|
||||
import { Invitation } from './invitation.js';
|
||||
import { Storage } from './storage.js';
|
||||
import { SyncServer } from '../utils/sync-server.js';
|
||||
import { HistoryService } from './history.js';
|
||||
import { ElectrumService } from './electrum.js';
|
||||
import { Invitation } from "./invitation.js";
|
||||
import { Storage } from "./storage.js";
|
||||
import { SyncServer } from "../utils/sync-server.js";
|
||||
import { HistoryService } from "./history.js";
|
||||
import { ElectrumService } from "./electrum.js";
|
||||
|
||||
import { EventEmitter } from '../utils/event-emitter.js';
|
||||
import { EventEmitter } from "../utils/event-emitter.js";
|
||||
|
||||
// TODO: Remove this. Exists to hash the seed for database namespace.
|
||||
import { createHash } from 'crypto';
|
||||
import { p2pkhTemplate } from '@xo-cash/templates';
|
||||
import { hexToBin } from '@bitauth/libauth';
|
||||
import { createHash } from "crypto";
|
||||
import { p2pkhTemplate } from "@xo-cash/templates";
|
||||
import { hexToBin } from "@bitauth/libauth";
|
||||
|
||||
export type AppEventMap = {
|
||||
'invitation-added': Invitation;
|
||||
'invitation-removed': Invitation;
|
||||
}
|
||||
"invitation-added": Invitation;
|
||||
"invitation-removed": Invitation;
|
||||
};
|
||||
|
||||
export interface AppConfig {
|
||||
syncServerUrl: string;
|
||||
@@ -38,14 +38,14 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
public config: AppConfig;
|
||||
public history: HistoryService;
|
||||
public electrum: ElectrumService;
|
||||
|
||||
|
||||
public invitations: Invitation[] = [];
|
||||
|
||||
|
||||
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
||||
// 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 seedHash = createHash("sha256").update(seed).digest("hex");
|
||||
|
||||
// We want to only prefix the file name
|
||||
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
||||
|
||||
@@ -66,13 +66,13 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
|
||||
await engine.setDefaultLockingParameters(
|
||||
generateTemplateIdentifier(p2pkhTemplate),
|
||||
'receiveOutput',
|
||||
'receiver',
|
||||
"receiveOutput",
|
||||
"receiver",
|
||||
);
|
||||
|
||||
// Create our own storage for the invitations
|
||||
const storage = await Storage.create(config.invitationStoragePath);
|
||||
const walletStorage = await storage.child(seedHash.slice(0, 8))
|
||||
const walletStorage = await storage.child(seedHash.slice(0, 8));
|
||||
|
||||
// Create the app service
|
||||
const electrum = new ElectrumService({
|
||||
@@ -86,23 +86,35 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
const allUnspentOutputs = await engine.listUnspentOutputsData();
|
||||
|
||||
// Get a set of all the invitation identifiers
|
||||
const allInvitationIdentifiers = new Set(allUnspentOutputs.map(output => output.invitationIdentifier));
|
||||
const allInvitationIdentifiers = new Set(
|
||||
allUnspentOutputs.map((output) => output.invitationIdentifier),
|
||||
);
|
||||
|
||||
// Iterate over the invitation identifiers and unreserve the outputs
|
||||
for (const invitationIdentifier of allInvitationIdentifiers) {
|
||||
// Get the outputs for the invitation
|
||||
const outputs = allUnspentOutputs.filter(output => output.invitationIdentifier === invitationIdentifier);
|
||||
const outputs = allUnspentOutputs.filter(
|
||||
(output) => output.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
// Unreserve the outputs
|
||||
await engine.unreserveResources(outputs.map(output => ({
|
||||
outpointTransactionHash: hexToBin(output.outpointTransactionHash),
|
||||
outpointIndex: output.outpointIndex,
|
||||
})), invitationIdentifier);
|
||||
await engine.unreserveResources(
|
||||
outputs.map((output) => ({
|
||||
outpointTransactionHash: hexToBin(output.outpointTransactionHash),
|
||||
outpointIndex: output.outpointIndex,
|
||||
})),
|
||||
invitationIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
return new AppService(engine, walletStorage, config, electrum);
|
||||
}
|
||||
|
||||
constructor(engine: Engine, storage: Storage, config: AppConfig, electrum: ElectrumService) {
|
||||
constructor(
|
||||
engine: Engine,
|
||||
storage: Storage,
|
||||
config: AppConfig,
|
||||
electrum: ElectrumService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.engine = engine;
|
||||
@@ -112,10 +124,17 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
this.history = new HistoryService(engine, this.invitations);
|
||||
}
|
||||
|
||||
async createInvitation(invitation: XOInvitation | string): Promise<Invitation> {
|
||||
async createInvitation(
|
||||
invitation: XOInvitation | string,
|
||||
): Promise<Invitation> {
|
||||
// Make sure the engine has the template imported
|
||||
const invitationStorage = this.storage.child('invitations')
|
||||
const invitationSyncServer = new SyncServer(this.config.syncServerUrl, typeof invitation === 'string' ? invitation : invitation.invitationIdentifier);
|
||||
const invitationStorage = this.storage.child("invitations");
|
||||
const invitationSyncServer = new SyncServer(
|
||||
this.config.syncServerUrl,
|
||||
typeof invitation === "string"
|
||||
? invitation
|
||||
: invitation.invitationIdentifier,
|
||||
);
|
||||
|
||||
const deps = {
|
||||
engine: this.engine,
|
||||
@@ -138,26 +157,31 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
this.invitations.push(invitation);
|
||||
|
||||
// Emit the invitation-added event
|
||||
this.emit('invitation-added', invitation);
|
||||
this.emit("invitation-added", invitation);
|
||||
}
|
||||
|
||||
async removeInvitation(invitation: Invitation): Promise<void> {
|
||||
// Remove the invitation from the invitations array
|
||||
this.invitations = this.invitations.filter(i => i !== invitation);
|
||||
this.invitations = this.invitations.filter((i) => i !== invitation);
|
||||
|
||||
// Emit the invitation-removed event
|
||||
this.emit('invitation-removed', invitation);
|
||||
this.emit("invitation-removed", invitation);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Get the invitations db
|
||||
const invitationsDb = this.storage.child('invitations');
|
||||
const invitationsDb = this.storage.child("invitations");
|
||||
|
||||
// Load invitations from storage
|
||||
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
||||
const invitations = (await invitationsDb.all()) as {
|
||||
key: string;
|
||||
value: XOInvitation;
|
||||
}[];
|
||||
|
||||
await Promise.all(invitations.map(async ({ key }) => {
|
||||
await this.createInvitation(key);
|
||||
}));
|
||||
await Promise.all(
|
||||
invitations.map(async ({ key }) => {
|
||||
await this.createInvitation(key);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fetchTransactionBlockHeight, initializeElectrumClient } from '@electrum-cash/protocol';
|
||||
import {
|
||||
fetchTransactionBlockHeight,
|
||||
initializeElectrumClient,
|
||||
} from "@electrum-cash/protocol";
|
||||
|
||||
export interface ElectrumServiceConfig {
|
||||
host?: string;
|
||||
@@ -15,13 +18,17 @@ export class ElectrumService {
|
||||
private clientPromise?: ReturnType<typeof initializeElectrumClient>;
|
||||
|
||||
constructor(config: ElectrumServiceConfig = {}) {
|
||||
this.host = config.host ?? process.env['ELECTRUM_HOST'] ?? 'bch.imaginary.cash';
|
||||
this.applicationIdentifier = 'xo-cli';
|
||||
this.host =
|
||||
config.host ?? process.env["ELECTRUM_HOST"] ?? "bch.imaginary.cash";
|
||||
this.applicationIdentifier = "xo-cli";
|
||||
}
|
||||
|
||||
private async getClient() {
|
||||
if (!this.clientPromise) {
|
||||
this.clientPromise = initializeElectrumClient(this.applicationIdentifier, this.host);
|
||||
this.clientPromise = initializeElectrumClient(
|
||||
this.applicationIdentifier,
|
||||
this.host,
|
||||
);
|
||||
}
|
||||
|
||||
return this.clientPromise;
|
||||
@@ -38,7 +45,7 @@ export class ElectrumService {
|
||||
|
||||
// Electrum returns numbers for known transactions
|
||||
// (e.g. >0 confirmed, 0/-1 unconfirmed variants).
|
||||
return typeof height === 'number';
|
||||
return typeof height === "number";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { binToHex } from '@bitauth/libauth';
|
||||
import { compileCashAssemblyString, type Engine } from '@xo-cash/engine';
|
||||
import type { UnspentOutputData } from '@xo-cash/state';
|
||||
import type { XOInvitation, XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
import type { Invitation } from './invitation.js';
|
||||
import { binToHex } from "@bitauth/libauth";
|
||||
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
|
||||
import type { UnspentOutputData } from "@xo-cash/state";
|
||||
import type {
|
||||
XOInvitation,
|
||||
XOInvitationCommit,
|
||||
XOInvitationVariableValue,
|
||||
XOTemplate,
|
||||
} from "@xo-cash/types";
|
||||
import type { Invitation } from "./invitation.js";
|
||||
|
||||
export type HistoryEntryKind = 'invitation' | 'utxo';
|
||||
export type HistoryEntryKind = "invitation" | "utxo";
|
||||
|
||||
export interface HistoryDescriptionParts {
|
||||
template: string;
|
||||
@@ -15,7 +20,7 @@ export interface HistoryDescriptionParts {
|
||||
}
|
||||
|
||||
export interface HistoryUtxoItem {
|
||||
kind: 'utxo';
|
||||
kind: "utxo";
|
||||
id: string;
|
||||
invitationIdentifier?: string;
|
||||
templateIdentifier: string;
|
||||
@@ -26,13 +31,13 @@ export interface HistoryUtxoItem {
|
||||
};
|
||||
valueSatoshis?: bigint;
|
||||
reserved?: boolean;
|
||||
direction: 'input' | 'output' | 'standalone';
|
||||
direction: "input" | "output" | "standalone";
|
||||
description: string;
|
||||
descriptionParts: HistoryDescriptionParts;
|
||||
}
|
||||
|
||||
export interface HistoryInvitationItem {
|
||||
kind: 'invitation';
|
||||
kind: "invitation";
|
||||
id: string;
|
||||
createdAtTimestamp: number;
|
||||
templateIdentifier: string;
|
||||
@@ -65,7 +70,7 @@ interface UtxoOriginContext {
|
||||
export class HistoryService {
|
||||
constructor(
|
||||
private engine: Engine,
|
||||
private invitations: Invitation[]
|
||||
private invitations: Invitation[],
|
||||
) {}
|
||||
|
||||
async extractEntities(invitation: XOInvitation): Promise<string[]> {
|
||||
@@ -80,12 +85,15 @@ export class HistoryService {
|
||||
// Iterating through each commit, extract the entity into a Map<entityId: string, roles: string[]>.
|
||||
// While we iterate through the commits, if we see a role declaration in the commit, we save that role onto the entity's roles array.
|
||||
// After we have iterated through all the commits, we can return the Map<entityId: string, roles: string[]>.
|
||||
async matchRolesToEntities(invitation: XOInvitation, entities: string[]): Promise<Record<string, string[]>> {
|
||||
async matchRolesToEntities(
|
||||
invitation: XOInvitation,
|
||||
entities: string[],
|
||||
): Promise<Record<string, string[]>> {
|
||||
const entitiesMap = new Map<string, Set<string>>();
|
||||
for (const entity of entities) {
|
||||
entitiesMap.set(entity, new Set());
|
||||
}
|
||||
|
||||
|
||||
// First pass, we are just going to try and find roleIdentifer values in the inputs, outputs, and variables.
|
||||
// TODO: Update this once the invitations use XPubs
|
||||
for (const commit of invitation.commits) {
|
||||
@@ -125,7 +133,10 @@ export class HistoryService {
|
||||
const outpointValueSatoshis = new Map<string, bigint>();
|
||||
|
||||
for (const utxo of allUtxos) {
|
||||
const outpointKey = this.getOutpointKey(utxo.outpointTransactionHash, utxo.outpointIndex);
|
||||
const outpointKey = this.getOutpointKey(
|
||||
utxo.outpointTransactionHash,
|
||||
utxo.outpointIndex,
|
||||
);
|
||||
ownOutpoints.add(outpointKey);
|
||||
ownLockingBytecodes.add(utxo.lockingBytecode);
|
||||
outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis));
|
||||
@@ -134,8 +145,14 @@ export class HistoryService {
|
||||
const contexts = new Map<string, InvitationContext>();
|
||||
for (const invitation of this.invitations) {
|
||||
const variables = this.extractInvitationVariables(invitation.data);
|
||||
const template = await this.engine.getTemplate(invitation.data.templateIdentifier) ?? null;
|
||||
const walletEntityIdentifier = this.resolveWalletEntityIdentifier(invitation, ownOutpoints, ownLockingBytecodes);
|
||||
const template =
|
||||
(await this.engine.getTemplate(invitation.data.templateIdentifier)) ??
|
||||
null;
|
||||
const walletEntityIdentifier = this.resolveWalletEntityIdentifier(
|
||||
invitation,
|
||||
ownOutpoints,
|
||||
ownLockingBytecodes,
|
||||
);
|
||||
contexts.set(invitation.data.invitationIdentifier, {
|
||||
invitation,
|
||||
template,
|
||||
@@ -155,24 +172,32 @@ export class HistoryService {
|
||||
|
||||
for (const context of contexts.values()) {
|
||||
const invitation = context.invitation.data;
|
||||
const templateName = context.template?.name ?? 'UnknownTemplate';
|
||||
const templateName = context.template?.name ?? "UnknownTemplate";
|
||||
const invitationOutputs = this.buildWalletOutputItemsForInvitation(
|
||||
context,
|
||||
allUtxos,
|
||||
invitationByOrigin,
|
||||
usedUtxoIds,
|
||||
);
|
||||
const roles = this.deriveWalletRolesForInvitation(context, invitationOutputs);
|
||||
const roles = this.deriveWalletRolesForInvitation(
|
||||
context,
|
||||
invitationOutputs,
|
||||
);
|
||||
const invitationInputs = this.buildWalletInputItemsForInvitation(
|
||||
context,
|
||||
roles[0],
|
||||
invitationOutputs.length > 0,
|
||||
outpointValueSatoshis,
|
||||
);
|
||||
const invitationDescription = this.deriveInvitationDescription(invitation, context.template, context.variables, roles[0]);
|
||||
const invitationDescription = this.deriveInvitationDescription(
|
||||
invitation,
|
||||
context.template,
|
||||
context.variables,
|
||||
roles[0],
|
||||
);
|
||||
|
||||
invitationItems.push({
|
||||
kind: 'invitation',
|
||||
kind: "invitation",
|
||||
id: `inv-${invitation.invitationIdentifier}`,
|
||||
createdAtTimestamp: invitation.createdAtTimestamp,
|
||||
templateIdentifier: invitation.templateIdentifier,
|
||||
@@ -196,39 +221,66 @@ export class HistoryService {
|
||||
const utxoId = this.getUtxoId(utxo);
|
||||
if (usedUtxoIds.has(utxoId)) continue;
|
||||
|
||||
const template = await this.engine.getTemplate(utxo.templateIdentifier) ?? null;
|
||||
const inferredRole = this.inferRoleFromOutputIdentifier(utxo.outputIdentifier);
|
||||
const description = this.deriveUtxoDescription(utxo, template, {}, inferredRole);
|
||||
standaloneUtxos.push(this.buildUtxoHistoryItem(
|
||||
const template =
|
||||
(await this.engine.getTemplate(utxo.templateIdentifier)) ?? null;
|
||||
const inferredRole = this.inferRoleFromOutputIdentifier(
|
||||
utxo.outputIdentifier,
|
||||
);
|
||||
const description = this.deriveUtxoDescription(
|
||||
utxo,
|
||||
description,
|
||||
template?.name ?? 'UnknownTemplate',
|
||||
template,
|
||||
{},
|
||||
inferredRole,
|
||||
'standalone',
|
||||
));
|
||||
);
|
||||
standaloneUtxos.push(
|
||||
this.buildUtxoHistoryItem(
|
||||
utxo,
|
||||
description,
|
||||
template?.name ?? "UnknownTemplate",
|
||||
inferredRole,
|
||||
"standalone",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return [ ...invitationItems, ...standaloneUtxos ];
|
||||
return [...invitationItems, ...standaloneUtxos];
|
||||
}
|
||||
|
||||
private buildWalletOutputItemsForInvitation(
|
||||
context: InvitationContext,
|
||||
allUtxos: UnspentOutputData[],
|
||||
invitationByOrigin: Map<string, UtxoOriginContext>,
|
||||
usedUtxoIds: Set<string>
|
||||
usedUtxoIds: Set<string>,
|
||||
): HistoryUtxoItem[] {
|
||||
const invitationId = context.invitation.data.invitationIdentifier;
|
||||
const outputs: HistoryUtxoItem[] = [];
|
||||
|
||||
for (const utxo of allUtxos) {
|
||||
const resolvedInvitationId = this.resolveInvitationIdentifierForUtxo(utxo, invitationByOrigin);
|
||||
const resolvedInvitationId = this.resolveInvitationIdentifierForUtxo(
|
||||
utxo,
|
||||
invitationByOrigin,
|
||||
);
|
||||
if (resolvedInvitationId !== invitationId) continue;
|
||||
|
||||
const role = this.resolveRoleIdentifierForUtxo(utxo, invitationByOrigin)
|
||||
?? this.inferRoleFromOutputIdentifier(utxo.outputIdentifier)
|
||||
?? 'receiver';
|
||||
const description = this.deriveUtxoDescription(utxo, context.template, context.variables, role);
|
||||
outputs.push(this.buildUtxoHistoryItem(utxo, description, context.template?.name ?? 'UnknownTemplate', role, 'output'));
|
||||
const role =
|
||||
this.resolveRoleIdentifierForUtxo(utxo, invitationByOrigin) ??
|
||||
this.inferRoleFromOutputIdentifier(utxo.outputIdentifier) ??
|
||||
"receiver";
|
||||
const description = this.deriveUtxoDescription(
|
||||
utxo,
|
||||
context.template,
|
||||
context.variables,
|
||||
role,
|
||||
);
|
||||
outputs.push(
|
||||
this.buildUtxoHistoryItem(
|
||||
utxo,
|
||||
description,
|
||||
context.template?.name ?? "UnknownTemplate",
|
||||
role,
|
||||
"output",
|
||||
),
|
||||
);
|
||||
usedUtxoIds.add(this.getUtxoId(utxo));
|
||||
}
|
||||
|
||||
@@ -244,22 +296,40 @@ export class HistoryService {
|
||||
const invitation = context.invitation.data;
|
||||
const commits = invitation.commits ?? [];
|
||||
const commitsByEntity = context.walletEntityIdentifier
|
||||
? commits.filter((commit) => commit.entityIdentifier === context.walletEntityIdentifier)
|
||||
? commits.filter(
|
||||
(commit) =>
|
||||
commit.entityIdentifier === context.walletEntityIdentifier,
|
||||
)
|
||||
: [];
|
||||
const commitsByRole = walletRole
|
||||
? commits.filter((commit) => this.deriveCommitRoleIdentifier(commit, invitation, context.template) === walletRole)
|
||||
? commits.filter(
|
||||
(commit) =>
|
||||
this.deriveCommitRoleIdentifier(
|
||||
commit,
|
||||
invitation,
|
||||
context.template,
|
||||
) === walletRole,
|
||||
)
|
||||
: [];
|
||||
|
||||
let relevantCommits = commitsByEntity.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||
let relevantCommits = commitsByEntity.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
if (relevantCommits.length === 0) {
|
||||
relevantCommits = commitsByRole.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||
relevantCommits = commitsByRole.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
if (relevantCommits.length === 0 && walletRole === 'sender') {
|
||||
relevantCommits = commits.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||
if (relevantCommits.length === 0 && walletRole === "sender") {
|
||||
relevantCommits = commits.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
// Sender fallback only when no wallet outputs were matched.
|
||||
if (relevantCommits.length === 0 && !hasWalletOutputs) {
|
||||
relevantCommits = commits.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||
relevantCommits = commits.filter(
|
||||
(commit) => (commit.data.inputs?.length ?? 0) > 0,
|
||||
);
|
||||
}
|
||||
|
||||
const txDescription = this.deriveTransactionActivityDescription(
|
||||
@@ -273,19 +343,28 @@ export class HistoryService {
|
||||
for (const commit of relevantCommits) {
|
||||
for (const input of commit.data.inputs ?? []) {
|
||||
const txHash = input.outpointTransactionHash
|
||||
? (input.outpointTransactionHash instanceof Uint8Array
|
||||
? binToHex(input.outpointTransactionHash)
|
||||
: String(input.outpointTransactionHash))
|
||||
: 'unknown-tx';
|
||||
? input.outpointTransactionHash instanceof Uint8Array
|
||||
? binToHex(input.outpointTransactionHash)
|
||||
: String(input.outpointTransactionHash)
|
||||
: "unknown-tx";
|
||||
const inputIndex = input.outpointIndex ?? -1;
|
||||
const inputIdentifier = input.inputIdentifier ?? 'input';
|
||||
const inputDescription = this.deriveInputDescription(inputIdentifier, context.template, context.variables);
|
||||
const templateName = context.template?.name ?? 'UnknownTemplate';
|
||||
const role = walletRole ?? 'sender';
|
||||
const inputValue = this.resolveInputSatoshis(txHash, inputIndex, outpointValueSatoshis, context.variables);
|
||||
const inputIdentifier = input.inputIdentifier ?? "input";
|
||||
const inputDescription = this.deriveInputDescription(
|
||||
inputIdentifier,
|
||||
context.template,
|
||||
context.variables,
|
||||
);
|
||||
const templateName = context.template?.name ?? "UnknownTemplate";
|
||||
const role = walletRole ?? "sender";
|
||||
const inputValue = this.resolveInputSatoshis(
|
||||
txHash,
|
||||
inputIndex,
|
||||
outpointValueSatoshis,
|
||||
context.variables,
|
||||
);
|
||||
|
||||
inputs.push({
|
||||
kind: 'utxo',
|
||||
kind: "utxo",
|
||||
id: `input-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${txHash}:${inputIndex}-${inputIdentifier}`,
|
||||
invitationIdentifier: invitation.invitationIdentifier,
|
||||
templateIdentifier: invitation.templateIdentifier,
|
||||
@@ -294,7 +373,7 @@ export class HistoryService {
|
||||
txid: txHash,
|
||||
index: inputIndex,
|
||||
},
|
||||
direction: 'input',
|
||||
direction: "input",
|
||||
valueSatoshis: inputValue,
|
||||
description: `${txDescription} - ${inputDescription}`,
|
||||
descriptionParts: {
|
||||
@@ -302,7 +381,8 @@ export class HistoryService {
|
||||
role,
|
||||
outputIdentifier: inputIdentifier,
|
||||
description: `${txDescription} - ${inputDescription}`,
|
||||
valueSatoshis: inputValue !== undefined ? Number(inputValue) : undefined,
|
||||
valueSatoshis:
|
||||
inputValue !== undefined ? Number(inputValue) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -316,10 +396,10 @@ export class HistoryService {
|
||||
description: string,
|
||||
templateName: string,
|
||||
roleIdentifier: string | undefined,
|
||||
direction: HistoryUtxoItem['direction']
|
||||
direction: HistoryUtxoItem["direction"],
|
||||
): HistoryUtxoItem {
|
||||
return {
|
||||
kind: 'utxo',
|
||||
kind: "utxo",
|
||||
id: this.getUtxoId(utxo),
|
||||
invitationIdentifier: utxo.invitationIdentifier || undefined,
|
||||
templateIdentifier: utxo.templateIdentifier,
|
||||
@@ -334,7 +414,7 @@ export class HistoryService {
|
||||
description,
|
||||
descriptionParts: {
|
||||
template: templateName,
|
||||
role: roleIdentifier ?? 'unknown',
|
||||
role: roleIdentifier ?? "unknown",
|
||||
outputIdentifier: utxo.outputIdentifier,
|
||||
description,
|
||||
valueSatoshis: utxo.valueSatoshis,
|
||||
@@ -351,54 +431,80 @@ export class HistoryService {
|
||||
*/
|
||||
private deriveWalletRolesForInvitation(
|
||||
context: InvitationContext,
|
||||
outputs: HistoryUtxoItem[]
|
||||
outputs: HistoryUtxoItem[],
|
||||
): string[] {
|
||||
const roles = new Set<string>();
|
||||
for (const output of outputs) {
|
||||
const outputRole = output.descriptionParts.role;
|
||||
if (outputRole && outputRole !== 'unknown') {
|
||||
if (outputRole && outputRole !== "unknown") {
|
||||
roles.add(outputRole);
|
||||
}
|
||||
}
|
||||
if (roles.size === 0 && outputs.length > 0) {
|
||||
roles.add('receiver');
|
||||
roles.add("receiver");
|
||||
}
|
||||
|
||||
const hasInputCommit = (context.walletEntityIdentifier
|
||||
? context.invitation.data.commits.filter((c) => c.entityIdentifier === context.walletEntityIdentifier)
|
||||
: context.invitation.data.commits
|
||||
const hasInputCommit = (
|
||||
context.walletEntityIdentifier
|
||||
? context.invitation.data.commits.filter(
|
||||
(c) => c.entityIdentifier === context.walletEntityIdentifier,
|
||||
)
|
||||
: context.invitation.data.commits
|
||||
).some((c) => (c.data.inputs?.length ?? 0) > 0);
|
||||
|
||||
if (hasInputCommit) roles.add('sender');
|
||||
if (!hasInputCommit && outputs.length === 0 && context.invitation.data.commits.some((c) => (c.data.inputs?.length ?? 0) > 0)) {
|
||||
roles.add('sender');
|
||||
if (hasInputCommit) roles.add("sender");
|
||||
if (
|
||||
!hasInputCommit &&
|
||||
outputs.length === 0 &&
|
||||
context.invitation.data.commits.some(
|
||||
(c) => (c.data.inputs?.length ?? 0) > 0,
|
||||
)
|
||||
) {
|
||||
roles.add("sender");
|
||||
}
|
||||
if (roles.size === 0) {
|
||||
const inferred = this.extractInvitationRoleIdentifier(context.invitation.data, context.template, context.walletEntityIdentifier);
|
||||
const inferred = this.extractInvitationRoleIdentifier(
|
||||
context.invitation.data,
|
||||
context.template,
|
||||
context.walletEntityIdentifier,
|
||||
);
|
||||
if (inferred) roles.add(inferred);
|
||||
}
|
||||
|
||||
return roles.size > 0 ? Array.from(roles) : [ 'unknown' ];
|
||||
return roles.size > 0 ? Array.from(roles) : ["unknown"];
|
||||
}
|
||||
|
||||
private extractInvitationVariables(invitation: XOInvitation): Record<string, XOInvitationVariableValue> {
|
||||
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
|
||||
return committedVariables.reduce((acc, variable) => {
|
||||
if (!variable.variableIdentifier) return acc;
|
||||
acc[variable.variableIdentifier] = variable.value;
|
||||
return acc;
|
||||
}, {} as Record<string, XOInvitationVariableValue>);
|
||||
private extractInvitationVariables(
|
||||
invitation: XOInvitation,
|
||||
): Record<string, XOInvitationVariableValue> {
|
||||
const committedVariables = invitation.commits.flatMap(
|
||||
(c) => c.data.variables ?? [],
|
||||
);
|
||||
return committedVariables.reduce(
|
||||
(acc, variable) => {
|
||||
if (!variable.variableIdentifier) return acc;
|
||||
acc[variable.variableIdentifier] = variable.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, XOInvitationVariableValue>,
|
||||
);
|
||||
}
|
||||
|
||||
private indexInvitationOutputsByUtxoOrigin(
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
||||
invitation: Invitation
|
||||
invitation: Invitation,
|
||||
): void {
|
||||
for (const commit of invitation.data.commits) {
|
||||
for (const output of commit.data.outputs ?? []) {
|
||||
if (!output.outputIdentifier || !output.lockingBytecode) continue;
|
||||
const lockingBytecodeHex = this.toLockingBytecodeHex(output.lockingBytecode);
|
||||
const key = this.getUtxoOriginKey(invitation.data.templateIdentifier, output.outputIdentifier, lockingBytecodeHex);
|
||||
const lockingBytecodeHex = this.toLockingBytecodeHex(
|
||||
output.lockingBytecode,
|
||||
);
|
||||
const key = this.getUtxoOriginKey(
|
||||
invitation.data.templateIdentifier,
|
||||
output.outputIdentifier,
|
||||
lockingBytecodeHex,
|
||||
);
|
||||
invitationByUtxoOrigin.set(key, {
|
||||
invitationIdentifier: invitation.data.invitationIdentifier,
|
||||
roleIdentifier: output.roleIdentifier,
|
||||
@@ -409,25 +515,33 @@ export class HistoryService {
|
||||
|
||||
private resolveInvitationIdentifierForUtxo(
|
||||
utxo: UnspentOutputData,
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
||||
): string | undefined {
|
||||
if (utxo.invitationIdentifier) return utxo.invitationIdentifier;
|
||||
const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode);
|
||||
const originKey = this.getUtxoOriginKey(
|
||||
utxo.templateIdentifier,
|
||||
utxo.outputIdentifier,
|
||||
utxo.lockingBytecode,
|
||||
);
|
||||
return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier;
|
||||
}
|
||||
|
||||
private resolveRoleIdentifierForUtxo(
|
||||
utxo: UnspentOutputData,
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
||||
): string | undefined {
|
||||
const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode);
|
||||
const originKey = this.getUtxoOriginKey(
|
||||
utxo.templateIdentifier,
|
||||
utxo.outputIdentifier,
|
||||
utxo.lockingBytecode,
|
||||
);
|
||||
return invitationByUtxoOrigin.get(originKey)?.roleIdentifier;
|
||||
}
|
||||
|
||||
private resolveWalletEntityIdentifier(
|
||||
invitation: Invitation,
|
||||
ownUtxoOutpointKeys: Set<string>,
|
||||
ownLockingBytecodes: Set<string>
|
||||
ownLockingBytecodes: Set<string>,
|
||||
): string | undefined {
|
||||
const scores = new Map<string, number>();
|
||||
const addScore = (entityIdentifier: string, delta: number): void => {
|
||||
@@ -437,17 +551,23 @@ export class HistoryService {
|
||||
for (const commit of invitation.data.commits) {
|
||||
for (const input of commit.data.inputs ?? []) {
|
||||
const txHash = input.outpointTransactionHash
|
||||
? (input.outpointTransactionHash instanceof Uint8Array
|
||||
? binToHex(input.outpointTransactionHash)
|
||||
: String(input.outpointTransactionHash))
|
||||
? input.outpointTransactionHash instanceof Uint8Array
|
||||
? binToHex(input.outpointTransactionHash)
|
||||
: String(input.outpointTransactionHash)
|
||||
: undefined;
|
||||
if (!txHash || input.outpointIndex === undefined) continue;
|
||||
if (ownUtxoOutpointKeys.has(this.getOutpointKey(txHash, input.outpointIndex))) {
|
||||
if (
|
||||
ownUtxoOutpointKeys.has(
|
||||
this.getOutpointKey(txHash, input.outpointIndex),
|
||||
)
|
||||
) {
|
||||
addScore(commit.entityIdentifier, 3);
|
||||
}
|
||||
}
|
||||
for (const output of commit.data.outputs ?? []) {
|
||||
const lockingBytecodeHex = output.lockingBytecode ? this.toLockingBytecodeHex(output.lockingBytecode) : undefined;
|
||||
const lockingBytecodeHex = output.lockingBytecode
|
||||
? this.toLockingBytecodeHex(output.lockingBytecode)
|
||||
: undefined;
|
||||
if (!lockingBytecodeHex) continue;
|
||||
if (ownLockingBytecodes.has(lockingBytecodeHex)) {
|
||||
addScore(commit.entityIdentifier, 2);
|
||||
@@ -457,7 +577,7 @@ export class HistoryService {
|
||||
|
||||
let bestEntity: string | undefined;
|
||||
let bestScore = 0;
|
||||
for (const [ entity, score ] of scores.entries()) {
|
||||
for (const [entity, score] of scores.entries()) {
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestEntity = entity;
|
||||
@@ -470,17 +590,20 @@ export class HistoryService {
|
||||
utxo: UnspentOutputData,
|
||||
template: XOTemplate | null,
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
roleIdentifier?: string
|
||||
roleIdentifier?: string,
|
||||
): string {
|
||||
const templateName = template?.name ?? 'UnknownTemplate';
|
||||
const role = roleIdentifier ?? 'unknown';
|
||||
const templateName = template?.name ?? "UnknownTemplate";
|
||||
const role = roleIdentifier ?? "unknown";
|
||||
const outputDef = template?.outputs?.[utxo.outputIdentifier];
|
||||
let detail = outputDef?.name ?? utxo.outputIdentifier;
|
||||
if (outputDef?.description) {
|
||||
try {
|
||||
detail = compileCashAssemblyString(outputDef.description, variables);
|
||||
} catch {
|
||||
detail = this.interpolateSimpleCashAssemblyVariables(outputDef.description, variables);
|
||||
detail = this.interpolateSimpleCashAssemblyVariables(
|
||||
outputDef.description,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
}
|
||||
return `[${templateName}:${role}] ${detail}`;
|
||||
@@ -490,19 +613,28 @@ export class HistoryService {
|
||||
invitation: XOInvitation,
|
||||
template: XOTemplate | null,
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
roleIdentifier?: string
|
||||
roleIdentifier?: string,
|
||||
): string {
|
||||
if (!template) return invitation.actionIdentifier;
|
||||
const action = template.actions?.[invitation.actionIdentifier];
|
||||
const transactionName = action?.transaction;
|
||||
const transaction = transactionName ? template.transactions?.[transactionName] : null;
|
||||
const role = roleIdentifier ?? 'unknown';
|
||||
const baseTemplate = transaction?.description ?? action?.description ?? action?.name ?? invitation.actionIdentifier;
|
||||
const transaction = transactionName
|
||||
? template.transactions?.[transactionName]
|
||||
: null;
|
||||
const role = roleIdentifier ?? "unknown";
|
||||
const baseTemplate =
|
||||
transaction?.description ??
|
||||
action?.description ??
|
||||
action?.name ??
|
||||
invitation.actionIdentifier;
|
||||
let detail = baseTemplate;
|
||||
try {
|
||||
detail = compileCashAssemblyString(baseTemplate, variables);
|
||||
} catch {
|
||||
detail = this.interpolateSimpleCashAssemblyVariables(baseTemplate, variables);
|
||||
detail = this.interpolateSimpleCashAssemblyVariables(
|
||||
baseTemplate,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
return `[${template.name}:${role}] ${detail}`;
|
||||
}
|
||||
@@ -510,16 +642,19 @@ export class HistoryService {
|
||||
private deriveInputDescription(
|
||||
inputIdentifier: string,
|
||||
template: XOTemplate | null,
|
||||
variables: Record<string, XOInvitationVariableValue>
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
): string {
|
||||
if (inputIdentifier === 'input') return 'Funding input';
|
||||
if (inputIdentifier === "input") return "Funding input";
|
||||
const inputDef = template?.inputs?.[inputIdentifier];
|
||||
if (!inputDef) return inputIdentifier;
|
||||
if (!inputDef.description) return inputDef.name ?? inputIdentifier;
|
||||
try {
|
||||
return compileCashAssemblyString(inputDef.description, variables);
|
||||
} catch {
|
||||
return this.interpolateSimpleCashAssemblyVariables(inputDef.description, variables);
|
||||
return this.interpolateSimpleCashAssemblyVariables(
|
||||
inputDef.description,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,30 +662,38 @@ export class HistoryService {
|
||||
invitation: XOInvitation,
|
||||
template: XOTemplate | null,
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
roleIdentifier?: string
|
||||
roleIdentifier?: string,
|
||||
): string {
|
||||
if (!template) return invitation.actionIdentifier;
|
||||
const action = template.actions?.[invitation.actionIdentifier];
|
||||
const transactionName = action?.transaction;
|
||||
const transaction = transactionName ? template.transactions?.[transactionName] : null;
|
||||
const roleData = roleIdentifier ? transaction?.roles?.[roleIdentifier] : undefined;
|
||||
const descriptionTemplate = roleData?.description
|
||||
?? transaction?.description
|
||||
?? roleData?.name
|
||||
?? transaction?.name
|
||||
?? action?.name
|
||||
?? invitation.actionIdentifier;
|
||||
const transaction = transactionName
|
||||
? template.transactions?.[transactionName]
|
||||
: null;
|
||||
const roleData = roleIdentifier
|
||||
? transaction?.roles?.[roleIdentifier]
|
||||
: undefined;
|
||||
const descriptionTemplate =
|
||||
roleData?.description ??
|
||||
transaction?.description ??
|
||||
roleData?.name ??
|
||||
transaction?.name ??
|
||||
action?.name ??
|
||||
invitation.actionIdentifier;
|
||||
try {
|
||||
return compileCashAssemblyString(descriptionTemplate, variables);
|
||||
} catch {
|
||||
return this.interpolateSimpleCashAssemblyVariables(descriptionTemplate, variables);
|
||||
return this.interpolateSimpleCashAssemblyVariables(
|
||||
descriptionTemplate,
|
||||
variables,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private deriveCommitRoleIdentifier(
|
||||
commit: XOInvitationCommit,
|
||||
invitation: XOInvitation,
|
||||
template: XOTemplate | null
|
||||
template: XOTemplate | null,
|
||||
): string | undefined {
|
||||
const explicitRoles = new Set<string>();
|
||||
for (const input of commit.data.inputs ?? []) {
|
||||
@@ -565,30 +708,42 @@ export class HistoryService {
|
||||
if (explicitRoles.size === 1) return Array.from(explicitRoles)[0];
|
||||
|
||||
const action = template?.actions?.[invitation.actionIdentifier];
|
||||
if ((commit.data.inputs?.length ?? 0) > 0 && action?.roles?.sender) return 'sender';
|
||||
if ((commit.data.variables?.length ?? 0) > 0 && action?.roles?.receiver) return 'receiver';
|
||||
if ((commit.data.inputs?.length ?? 0) > 0 && action?.roles?.sender)
|
||||
return "sender";
|
||||
if ((commit.data.variables?.length ?? 0) > 0 && action?.roles?.receiver)
|
||||
return "receiver";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractInvitationRoleIdentifier(
|
||||
invitation: XOInvitation,
|
||||
template: XOTemplate | null,
|
||||
walletEntityIdentifier?: string
|
||||
walletEntityIdentifier?: string,
|
||||
): string | undefined {
|
||||
if (walletEntityIdentifier) {
|
||||
const commits = invitation.commits.filter((commit) => commit.entityIdentifier === walletEntityIdentifier);
|
||||
const commits = invitation.commits.filter(
|
||||
(commit) => commit.entityIdentifier === walletEntityIdentifier,
|
||||
);
|
||||
for (const commit of commits) {
|
||||
const role = this.deriveCommitRoleIdentifier(commit, invitation, template);
|
||||
const role = this.deriveCommitRoleIdentifier(
|
||||
commit,
|
||||
invitation,
|
||||
template,
|
||||
);
|
||||
if (role) return role;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private inferRoleFromOutputIdentifier(outputIdentifier: string): string | undefined {
|
||||
private inferRoleFromOutputIdentifier(
|
||||
outputIdentifier: string,
|
||||
): string | undefined {
|
||||
const normalized = outputIdentifier.toLowerCase();
|
||||
if (normalized.includes('receive') || normalized.includes('request')) return 'receiver';
|
||||
if (normalized.includes('change') || normalized.includes('send')) return 'sender';
|
||||
if (normalized.includes("receive") || normalized.includes("request"))
|
||||
return "receiver";
|
||||
if (normalized.includes("change") || normalized.includes("send"))
|
||||
return "sender";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -596,7 +751,7 @@ export class HistoryService {
|
||||
txHash: string,
|
||||
index: number,
|
||||
outpointValueSatoshis: Map<string, bigint>,
|
||||
variables: Record<string, XOInvitationVariableValue>
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
): bigint | undefined {
|
||||
const outpointKey = this.getOutpointKey(txHash, index);
|
||||
const matchedValue = outpointValueSatoshis.get(outpointKey);
|
||||
@@ -622,22 +777,32 @@ export class HistoryService {
|
||||
return `${txid}:${index}`;
|
||||
}
|
||||
|
||||
private getUtxoOriginKey(templateIdentifier: string, outputIdentifier: string, lockingBytecodeHex: string): string {
|
||||
private getUtxoOriginKey(
|
||||
templateIdentifier: string,
|
||||
outputIdentifier: string,
|
||||
lockingBytecodeHex: string,
|
||||
): string {
|
||||
return `${templateIdentifier}:${outputIdentifier}:${lockingBytecodeHex}`;
|
||||
}
|
||||
|
||||
private toLockingBytecodeHex(lockingBytecode: string | Uint8Array): string {
|
||||
if (typeof lockingBytecode === 'string') return lockingBytecode;
|
||||
if (typeof lockingBytecode === "string") return lockingBytecode;
|
||||
return binToHex(lockingBytecode);
|
||||
}
|
||||
|
||||
private interpolateSimpleCashAssemblyVariables(
|
||||
text: string,
|
||||
variables: Record<string, XOInvitationVariableValue>
|
||||
variables: Record<string, XOInvitationVariableValue>,
|
||||
): string {
|
||||
return text.replace(/\$\(<([^>]+)>\)/g, (match, variableIdentifier: string) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) return match;
|
||||
return String(variables[variableIdentifier]);
|
||||
});
|
||||
return text.replace(
|
||||
/\$\(<([^>]+)>\)/g,
|
||||
(match, variableIdentifier: string) => {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)
|
||||
)
|
||||
return match;
|
||||
return String(variables[variableIdentifier]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +1,84 @@
|
||||
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
||||
import { hasInvitationExpired, mergeInvitationCommits } from '@xo-cash/engine';
|
||||
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable, XOInvitationVariableValue } from '@xo-cash/types';
|
||||
import type { UnspentOutputData } from '@xo-cash/state';
|
||||
import { binToHex, encodeTransaction, generateTransaction, hashTransaction, hexToBin } from '@bitauth/libauth';
|
||||
import type {
|
||||
AcceptInvitationParameters,
|
||||
AppendInvitationParameters,
|
||||
Engine,
|
||||
FindSuitableResourcesParameters,
|
||||
} from "@xo-cash/engine";
|
||||
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
||||
import type {
|
||||
XOInvitation,
|
||||
XOInvitationCommit,
|
||||
XOInvitationInput,
|
||||
XOInvitationOutput,
|
||||
XOInvitationVariable,
|
||||
XOInvitationVariableValue,
|
||||
} from "@xo-cash/types";
|
||||
import type { UnspentOutputData } from "@xo-cash/state";
|
||||
import {
|
||||
binToHex,
|
||||
encodeTransaction,
|
||||
generateTransaction,
|
||||
hashTransaction,
|
||||
hexToBin,
|
||||
} from "@bitauth/libauth";
|
||||
|
||||
import type { SSEvent } from '../utils/sse-client.js';
|
||||
import type { SyncServer } from '../utils/sync-server.js';
|
||||
import type { Storage } from './storage.js';
|
||||
import type { ElectrumService } from './electrum.js';
|
||||
import type { SSEvent } from "../utils/sse-client.js";
|
||||
import type { SyncServer } from "../utils/sync-server.js";
|
||||
import type { Storage } from "./storage.js";
|
||||
import type { ElectrumService } from "./electrum.js";
|
||||
|
||||
import { EventEmitter } from '../utils/event-emitter.js'
|
||||
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||
import { compileCashAssemblyString } from '@xo-cash/engine';
|
||||
import { EventEmitter } from "../utils/event-emitter.js";
|
||||
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
|
||||
import { compileCashAssemblyString } from "@xo-cash/engine";
|
||||
|
||||
export type InvitationEventMap = {
|
||||
'invitation-updated': XOInvitation;
|
||||
'invitation-status-changed': string;
|
||||
}
|
||||
"invitation-updated": XOInvitation;
|
||||
"invitation-status-changed": string;
|
||||
};
|
||||
|
||||
export type InvitationDependencies = {
|
||||
syncServer: SyncServer;
|
||||
storage: Storage;
|
||||
engine: Engine;
|
||||
electrum: ElectrumService;
|
||||
}
|
||||
};
|
||||
|
||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* Create an invitation and start the SSE Session required for it.
|
||||
*/
|
||||
static async create(invitation: XOInvitation | string, dependencies: InvitationDependencies): Promise<Invitation> {
|
||||
static async create(
|
||||
invitation: XOInvitation | string,
|
||||
dependencies: InvitationDependencies,
|
||||
): Promise<Invitation> {
|
||||
// If the invitation is a string, its probably an invitation identifier.
|
||||
// We will try to find the data then just call the create method again, but this time with the data.
|
||||
if(typeof invitation === 'string') {
|
||||
if (typeof invitation === "string") {
|
||||
// Try to get the invitation from the storage
|
||||
const invitationFromStorage = await dependencies.storage.get(invitation);
|
||||
if (invitationFromStorage) {
|
||||
return this.create(invitationFromStorage, dependencies);
|
||||
}
|
||||
|
||||
|
||||
// Try to get the invitation from the sync server
|
||||
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
||||
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
|
||||
const invitationFromSyncServer =
|
||||
await dependencies.syncServer.getInvitation(invitation);
|
||||
if (
|
||||
invitationFromSyncServer &&
|
||||
invitationFromSyncServer.invitationIdentifier === invitation
|
||||
) {
|
||||
return this.create(invitationFromSyncServer, dependencies);
|
||||
}
|
||||
|
||||
// We cant find it. Throw an error.
|
||||
throw new Error(`Invitation not found in local or remote storage: ${invitation}`);
|
||||
throw new Error(
|
||||
`Invitation not found in local or remote storage: ${invitation}`,
|
||||
);
|
||||
}
|
||||
|
||||
const template = await dependencies.engine.getTemplate(invitation.templateIdentifier);
|
||||
const template = await dependencies.engine.getTemplate(
|
||||
invitation.templateIdentifier,
|
||||
);
|
||||
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
||||
@@ -68,11 +97,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* The invitation data.
|
||||
*/
|
||||
public data: XOInvitation = {
|
||||
invitationIdentifier: '',
|
||||
invitationIdentifier: "",
|
||||
commits: [],
|
||||
createdAtTimestamp: 0,
|
||||
templateIdentifier: '',
|
||||
actionIdentifier: '',
|
||||
templateIdentifier: "",
|
||||
actionIdentifier: "",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -95,15 +124,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||
*/
|
||||
public status: string = 'unknown';
|
||||
public status: string = "unknown";
|
||||
|
||||
/**
|
||||
* Create an invitation and start the SSE Session required for it.
|
||||
*/
|
||||
constructor(
|
||||
invitation: XOInvitation,
|
||||
dependencies: InvitationDependencies
|
||||
) {
|
||||
constructor(invitation: XOInvitation, dependencies: InvitationDependencies) {
|
||||
super();
|
||||
|
||||
this.data = invitation;
|
||||
@@ -112,12 +138,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.storage = dependencies.storage;
|
||||
this.electrum = dependencies.electrum;
|
||||
|
||||
// I cannot express this enough, but the event handler does not need a clean up.
|
||||
// There is this beautiful thing called a "garbage collector". Once this class is removed from scope (removed from the invitations array) all the references
|
||||
// will be removed, including the SSE Session (and therefore this handler).
|
||||
this.syncServer.on('message', this.handleSSEMessage.bind(this));
|
||||
// Create a listerner for the messages from the SSE Session (sync server)
|
||||
this.syncServer.on("message", this.handleSSEMessage.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the invitation - Connect sync server and download latest invitation data.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Connect to the sync server and get the invitation (in parallel)
|
||||
const [_, invitation] = await Promise.all([
|
||||
@@ -129,7 +156,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
const sseCommits = this.data.commits;
|
||||
|
||||
// Merge the commits
|
||||
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
|
||||
const combinedCommits = this.mergeCommits(
|
||||
sseCommits,
|
||||
invitation?.commits ?? [],
|
||||
);
|
||||
|
||||
// Set the invitation data with the combined commits
|
||||
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
||||
@@ -146,15 +176,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
|
||||
/**
|
||||
* Handle an SSE message.
|
||||
*
|
||||
*
|
||||
* 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.
|
||||
* Why this level of thought is required is beyond me. We should be given a `mergeCommits` method or "something" that lets us take whole invitation and merge commits into it.
|
||||
* NOTE: signInvitation does merge the commits... But we want to be able to add commits in *before* signing the invitation. So, we are just going to receive a single commit at a time, then just invitation.commits.push(commit); to get around this.
|
||||
* I hope we dont end up with duplicate commits :/... We also dont have a way to list invitiations, which is an... interesting choice.
|
||||
*/
|
||||
private handleSSEMessage(event: SSEvent): void {
|
||||
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||
if (data.topic === 'invitation-updated') {
|
||||
if (data.topic === "invitation-updated") {
|
||||
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
||||
|
||||
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
||||
@@ -162,7 +189,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
}
|
||||
|
||||
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
||||
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
||||
const newCommits = this.mergeCommits(
|
||||
this.data.commits,
|
||||
invitation.commits,
|
||||
);
|
||||
|
||||
// Set the new commits
|
||||
this.data = { ...this.data, commits: newCommits };
|
||||
@@ -171,7 +201,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.updateStatus().catch(() => {});
|
||||
|
||||
// Emit the updated event
|
||||
this.emit('invitation-updated', this.data);
|
||||
this.emit("invitation-updated", this.data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,16 +211,19 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* @param additional - The additional commits
|
||||
* @returns The merged commits
|
||||
*/
|
||||
private mergeCommits(initial: XOInvitationCommit[], additional: XOInvitationCommit[]): XOInvitationCommit[] {
|
||||
private mergeCommits(
|
||||
initial: XOInvitationCommit[],
|
||||
additional: XOInvitationCommit[],
|
||||
): XOInvitationCommit[] {
|
||||
// Create a map of the initial commits
|
||||
const initialMap = new Map<string, XOInvitationCommit>();
|
||||
for(const commit of initial) {
|
||||
for (const commit of initial) {
|
||||
initialMap.set(commit.commitIdentifier, commit);
|
||||
}
|
||||
|
||||
// Merge the additional commits
|
||||
// Merge the additional commits
|
||||
// TODO: They are immutable? So, it should be fine to "ovewrite" existing commits as it should be the same data, right?
|
||||
for(const commit of additional) {
|
||||
for (const commit of additional) {
|
||||
initialMap.set(commit.commitIdentifier, commit);
|
||||
}
|
||||
|
||||
@@ -224,35 +257,39 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
try {
|
||||
missingReqs = await this.engine.listMissingRequirements(this.data);
|
||||
} catch {
|
||||
return 'unknown';
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const hasMissing =
|
||||
(missingReqs.variables?.length ?? 0) > 0 ||
|
||||
(missingReqs.inputs?.length ?? 0) > 0 ||
|
||||
(missingReqs.outputs?.length ?? 0) > 0 ||
|
||||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
|
||||
(missingReqs.roles !== undefined &&
|
||||
Object.keys(missingReqs.roles).length > 0);
|
||||
|
||||
const hasSignedCommit = this.hasSignedCommitInInvitation();
|
||||
|
||||
if (!hasMissing) {
|
||||
const transactionHash = await this.deriveTransactionHash();
|
||||
if (transactionHash && await this.electrum.hasSeenTransaction(transactionHash)) {
|
||||
return 'complete';
|
||||
if (
|
||||
transactionHash &&
|
||||
(await this.electrum.hasSeenTransaction(transactionHash))
|
||||
) {
|
||||
return "complete";
|
||||
}
|
||||
}
|
||||
|
||||
if (hasInvitationExpired(this.data)) {
|
||||
return 'expired';
|
||||
return "expired";
|
||||
}
|
||||
|
||||
if (!hasMissing && hasSignedCommit) {
|
||||
return 'ready';
|
||||
return "ready";
|
||||
}
|
||||
if (hasMissing && hasSignedCommit) {
|
||||
return 'signed';
|
||||
return "signed";
|
||||
}
|
||||
return 'actionable';
|
||||
return "actionable";
|
||||
}
|
||||
|
||||
private hasSignedCommitInInvitation(): boolean {
|
||||
@@ -274,7 +311,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
*/
|
||||
private async deriveTransactionHash(): Promise<string | undefined> {
|
||||
try {
|
||||
const template = await this.engine.getTemplate(this.data.templateIdentifier);
|
||||
const template = await this.engine.getTemplate(
|
||||
this.data.templateIdentifier,
|
||||
);
|
||||
if (!template) return undefined;
|
||||
|
||||
const mergedCommit = mergeInvitationCommits(this.data, template);
|
||||
@@ -291,9 +330,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
|
||||
if (!transactionResult.success) return undefined;
|
||||
|
||||
const transactionHex = binToHex(encodeTransaction(transactionResult.transaction));
|
||||
const transactionHex = binToHex(
|
||||
encodeTransaction(transactionResult.transaction),
|
||||
);
|
||||
const rawHash: unknown = hashTransaction(hexToBin(transactionHex));
|
||||
if (typeof rawHash === 'string') return rawHash;
|
||||
if (typeof rawHash === "string") return rawHash;
|
||||
if (rawHash instanceof Uint8Array) return binToHex(rawHash);
|
||||
return undefined;
|
||||
} catch {
|
||||
@@ -307,7 +348,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
private async updateStatus(): Promise<void> {
|
||||
const status = await this.computeStatus();
|
||||
this.status = status;
|
||||
this.emit('invitation-status-changed', status);
|
||||
this.emit("invitation-status-changed", status);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,8 +433,15 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* Generate the locking bytecode for the invitation
|
||||
* TODO: Find out if this has side-effects or needs special handling
|
||||
*/
|
||||
async generateLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
||||
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
|
||||
async generateLockingBytecode(
|
||||
outputIdentifier: string,
|
||||
roleIdentifier?: string,
|
||||
): Promise<string> {
|
||||
return this.engine.generateLockingBytecode(
|
||||
this.data.templateIdentifier,
|
||||
outputIdentifier,
|
||||
roleIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
|
||||
@@ -412,9 +460,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.syncServer.publishInvitation(this.data);
|
||||
}
|
||||
|
||||
async findSuitableResources(options: Partial<FindSuitableResourcesParameters> = {}): Promise<UnspentOutputData[]> {
|
||||
async findSuitableResources(
|
||||
options: Partial<FindSuitableResourcesParameters> = {},
|
||||
): Promise<UnspentOutputData[]> {
|
||||
// Find the suitable resources
|
||||
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
|
||||
const { unspentOutputs } = await this.engine.findSuitableResources(
|
||||
this.data,
|
||||
options,
|
||||
);
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
@@ -458,8 +511,15 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
/**
|
||||
* Get the locking bytecode for the invitation
|
||||
*/
|
||||
async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
||||
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
|
||||
async getLockingBytecode(
|
||||
outputIdentifier: string,
|
||||
roleIdentifier?: string,
|
||||
): Promise<string> {
|
||||
return this.engine.generateLockingBytecode(
|
||||
this.data.templateIdentifier,
|
||||
outputIdentifier,
|
||||
roleIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,33 +530,49 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// If an output identifier is provided, find all outputs with that identifier, and its valueSatoshis identifier back to the variables
|
||||
if (outputIdentifier) {
|
||||
// Get the valueSatoshis identifier from the template
|
||||
const template = await this.engine.getTemplate(this.data.templateIdentifier);
|
||||
const template = await this.engine.getTemplate(
|
||||
this.data.templateIdentifier,
|
||||
);
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`);
|
||||
throw new Error(
|
||||
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
const output = template.outputs[outputIdentifier];
|
||||
if (!output) {
|
||||
throw new Error(`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`);
|
||||
throw new Error(
|
||||
`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
const valueSatoshisIdentifier = output.valueSatoshis;
|
||||
if (!valueSatoshisIdentifier) {
|
||||
throw new Error(`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`);
|
||||
throw new Error(
|
||||
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create a list of all the variables from the commits
|
||||
const variables = this.data.commits.flatMap(c => c.data?.variables ?? []);
|
||||
const variables = this.data.commits.flatMap(
|
||||
(c) => c.data?.variables ?? [],
|
||||
);
|
||||
|
||||
// Create a dictionary of the variables
|
||||
const formattedVariables = variables.reduce((acc, v) => {
|
||||
acc[v.variableIdentifier ?? ''] = v.value;
|
||||
return acc;
|
||||
}, {} as Record<string, XOInvitationVariableValue>);
|
||||
|
||||
const formattedVariables = variables.reduce(
|
||||
(acc, v) => {
|
||||
acc[v.variableIdentifier ?? ""] = v.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, XOInvitationVariableValue>,
|
||||
);
|
||||
|
||||
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
|
||||
const valueSatoshis = await compileCashAssemblyString(String(valueSatoshisIdentifier), formattedVariables);
|
||||
|
||||
const valueSatoshis = await compileCashAssemblyString(
|
||||
String(valueSatoshisIdentifier),
|
||||
formattedVariables,
|
||||
);
|
||||
|
||||
// Return the value satoshis as a bigint
|
||||
// TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression
|
||||
return BigInt(valueSatoshis);
|
||||
@@ -505,31 +581,43 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// If we didnt get an output identifier, go through the action outputs and sum the valueSatoshis
|
||||
const action = this.data.actionIdentifier;
|
||||
if (!action) {
|
||||
throw new Error(`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`);
|
||||
throw new Error(
|
||||
`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the template
|
||||
const template = await this.engine.getTemplate(this.data.templateIdentifier);
|
||||
const template = await this.engine.getTemplate(
|
||||
this.data.templateIdentifier,
|
||||
);
|
||||
if (!template) {
|
||||
throw new Error(`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`);
|
||||
throw new Error(
|
||||
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the transaction ID from the action
|
||||
const transactionID = template.actions[action]?.transaction
|
||||
const transactionID = template.actions[action]?.transaction;
|
||||
if (!transactionID) {
|
||||
throw new Error(`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`);
|
||||
throw new Error(
|
||||
`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the transaction from the template
|
||||
const transaction = template.transactions?.[transactionID];
|
||||
if (!transaction) {
|
||||
throw new Error(`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`);
|
||||
throw new Error(
|
||||
`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Get the outputs from the transaction
|
||||
const outputs = transaction.outputs;
|
||||
if (!outputs) {
|
||||
throw new Error(`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`);
|
||||
throw new Error(
|
||||
`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create a value to store the cummulative total of the outputs
|
||||
@@ -537,7 +625,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
|
||||
// Iterate through the outputs and sum the valueSatoshis
|
||||
for (const output of outputs) {
|
||||
if (typeof output === 'string') {
|
||||
if (typeof output === "string") {
|
||||
totalSats += await this.getSatsOut(output);
|
||||
} else {
|
||||
totalSats += await this.getSatsOut(output.output);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { decodeExtendedJson, encodeExtendedJson } from '../utils/ext-json.js';
|
||||
import Database from "better-sqlite3";
|
||||
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
||||
|
||||
export class Storage {
|
||||
static async create(dbPath: string): Promise<Storage> {
|
||||
@@ -7,9 +7,13 @@ export class Storage {
|
||||
const database = new Database(dbPath);
|
||||
|
||||
// Create the storage table if it doesn't exist
|
||||
database.prepare('CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY, value TEXT)').run();
|
||||
database
|
||||
.prepare(
|
||||
"CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY, value TEXT)",
|
||||
)
|
||||
.run();
|
||||
|
||||
return new Storage(database, '');
|
||||
return new Storage(database, "");
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -39,43 +43,50 @@ export class Storage {
|
||||
|
||||
// Insert or replace the value into the database with full key (including basePath)
|
||||
const fullKey = this.getFullKey(key);
|
||||
this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue);
|
||||
this.database
|
||||
.prepare("INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)")
|
||||
.run(fullKey, encodedValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all key-value pairs from this storage namespace (shallow only - no nested children)
|
||||
*/
|
||||
async all(): Promise<{ key: string; value: any }[]> {
|
||||
let query = 'SELECT key, value FROM storage';
|
||||
let query = "SELECT key, value FROM storage";
|
||||
const params: any[] = [];
|
||||
|
||||
if (this.basePath) {
|
||||
// Filter by basePath prefix
|
||||
query += ' WHERE key LIKE ?';
|
||||
query += " WHERE key LIKE ?";
|
||||
params.push(`${this.basePath}.%`);
|
||||
}
|
||||
|
||||
// Get all the rows from the database
|
||||
const rows = await this.database.prepare(query).all(...params) as { key: string; value: any }[];
|
||||
const rows = (await this.database.prepare(query).all(...params)) as {
|
||||
key: string;
|
||||
value: any;
|
||||
}[];
|
||||
|
||||
// Filter for shallow results (only direct children)
|
||||
const filteredRows = rows.filter(row => {
|
||||
const filteredRows = rows.filter((row) => {
|
||||
const strippedKey = this.stripBasePath(row.key);
|
||||
// Only include keys that don't have additional dots (no deeper nesting)
|
||||
return !strippedKey.includes('.');
|
||||
return !strippedKey.includes(".");
|
||||
});
|
||||
|
||||
// Decode the extended json objects and strip basePath from keys
|
||||
return filteredRows.map(row => ({
|
||||
key: this.stripBasePath(row.key),
|
||||
value: decodeExtendedJson(row.value)
|
||||
return filteredRows.map((row) => ({
|
||||
key: this.stripBasePath(row.key),
|
||||
value: decodeExtendedJson(row.value),
|
||||
}));
|
||||
}
|
||||
|
||||
async get(key: string): Promise<any> {
|
||||
// Get the row from the database using full key
|
||||
const fullKey = this.getFullKey(key);
|
||||
const row = await this.database.prepare('SELECT value FROM storage WHERE key = ?').get(fullKey) as { value: any };
|
||||
const row = (await this.database
|
||||
.prepare("SELECT value FROM storage WHERE key = ?")
|
||||
.get(fullKey)) as { value: any };
|
||||
|
||||
// Return null if not found
|
||||
if (!row) return null;
|
||||
@@ -87,20 +98,22 @@ export class Storage {
|
||||
async remove(key: string): Promise<void> {
|
||||
// Delete using full key
|
||||
const fullKey = this.getFullKey(key);
|
||||
this.database.prepare('DELETE FROM storage WHERE key = ?').run(fullKey);
|
||||
this.database.prepare("DELETE FROM storage WHERE key = ?").run(fullKey);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
if (this.basePath) {
|
||||
// Clear only items under this namespace
|
||||
this.database.prepare('DELETE FROM storage WHERE key LIKE ?').run(`${this.basePath}.%`);
|
||||
this.database
|
||||
.prepare("DELETE FROM storage WHERE key LIKE ?")
|
||||
.run(`${this.basePath}.%`);
|
||||
} else {
|
||||
// Clear everything
|
||||
this.database.prepare('DELETE FROM storage').run();
|
||||
this.database.prepare("DELETE FROM storage").run();
|
||||
}
|
||||
}
|
||||
|
||||
child(key: string): Storage {
|
||||
return new Storage(this.database, this.getFullKey(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user