From dedfb69dff39e7172e9389a53915af05cdd29e54 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 4 May 2026 11:36:09 +0000 Subject: [PATCH] Fix history for the 100th time. Fix role resolution in the invitation screen --- src/services/history.ts | 1095 ++++++++--------- src/tui/screens/WalletState.tsx | 50 +- .../screens/invitations/InvitationScreen.tsx | 72 +- src/utils/history-utils.ts | 126 +- 4 files changed, 663 insertions(+), 680 deletions(-) diff --git a/src/services/history.ts b/src/services/history.ts index 544500e..a70bc38 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -1,106 +1,433 @@ -import { binToHex } from "@bitauth/libauth"; -import { - compileCashAssemblyString, - type Engine, - listInvitationCommitsByEntity, -} from "@xo-cash/engine"; -import type { UnspentOutputData } from "@xo-cash/state"; +import { binToHex, hexToBin, sha256 } from "@bitauth/libauth"; +import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; +import type { ScriptHashData, UnspentOutputData } from "@xo-cash/state"; import type { XOInvitation, XOInvitationCommit, + XOInvitationInput, + XOInvitationOutput, XOInvitationVariableValue, XOTemplate, } from "@xo-cash/types"; import type { Invitation } from "./invitation.js"; -export type HistoryEntryKind = "invitation" | "utxo"; +export type WalletHistorySource = "invitation" | "utxo"; -export interface HistoryDescriptionParts { - template: string; - role: string; - outputIdentifier: string; - description: string; - valueSatoshis?: number; -} - -export interface HistoryUtxoItem { - kind: "utxo"; +export type WalletHistoryInput = { id: string; - invitationIdentifier?: string; - templateIdentifier: string; - outputIdentifier: string; + commitIdentifier?: string; + inputIdentifier?: string; + role?: string; + description: string; + valueSatoshis?: bigint; outpoint: { txid: string; index: number; }; - valueSatoshis?: bigint; - reserved?: boolean; - direction: "input" | "output" | "standalone"; - description: string; - descriptionParts: HistoryDescriptionParts; -} + scriptHash?: string; +}; -export interface HistoryInvitationItem { - kind: "invitation"; +export type WalletHistoryOutput = { id: string; - createdAtTimestamp: number; + commitIdentifier?: string; + outputIdentifier?: string; + role?: string; + description: string; + valueSatoshis?: bigint; + outpoint?: { + txid: string; + index: number; + }; + lockingBytecode?: string; + scriptHash?: string; + reserved?: boolean; +}; + +export type WalletHistoryItem = { + id: string; + source: WalletHistorySource; + invitationIdentifier?: string; + createdAtTimestamp?: number; templateIdentifier: string; - invitationIdentifier: string; + template: string; + action?: string; roles: string[]; description: string; - descriptionParts: { - template: string; - roles: string[]; - description: string; - }; - inputs: HistoryUtxoItem[]; - outputs: HistoryUtxoItem[]; -} + valueSatoshis: bigint; + inputs: WalletHistoryInput[]; + outputs: WalletHistoryOutput[]; +}; -export type HistoryItem = HistoryInvitationItem | HistoryUtxoItem; +export type HistoryItem = WalletHistoryItem; interface InvitationContext { invitation: Invitation; template: XOTemplate | null; variables: Record; - walletCommits: XOInvitationCommit[]; - walletEntityIdentifier?: string; } -interface UtxoOriginContext { - invitationIdentifier: string; - roleIdentifier?: string; +interface UtxoContext { + utxo: UnspentOutputData; + scriptHashData?: ScriptHashData; + template: XOTemplate | null; } +interface WalletMetadataIndex { + scriptHashDataByScriptHash: Map; +} + +/* + * This needs a thorough and significant rewrite and design. + * I've tried to fundamental approaches so far: + * - UTXO first + * - Invitation first + * + * The issue is that neither of these end up being simple or effective + * UTXO first makes tracking utxos across invitations extremely difficult. So if you receive a UTXO from an invitation and then spend it on another, you wont even see that old invitation. + * Invitation first makes fitting UTXOs that dont have an invitation (say if someone sent directly to your address) extremely difficult. You end up having to run a UTXO first pass anyway, and then end up with conflicts around resolved roles. + * Inferring roles is also extremely difficult. We cant just say "does this have an output for our P2PKH receiving roll? it does? Ok, we are a receiver" because this would match `true` because of our change outputs. + * If anyone has any idea of how to address this without tying knots of spaghetti, please let me know. + * This has been rewritten multiple times to try and simplify it, but its still extremely hard to follow and understand, while not even providing information that we want. + */ + export class HistoryService { constructor( private engine: Engine, private invitations: Invitation[], ) {} - extractEntities(invitation: XOInvitation): Record { - return listInvitationCommitsByEntity(invitation); - } + async getHistory(): Promise { + const allUtxos = await this.engine.listUnspentOutputsData(); + const metadataIndex = await this.buildWalletMetadataIndex(allUtxos); + const invitationContexts = await this.buildInvitationContextIndex(); + const utxoContexts = await Promise.all( + allUtxos.map((utxo) => this.buildUtxoContext(utxo, metadataIndex)), + ); - // Entities are currently static per invitation. So, we can try to match the roles to entities by: - // Iterating through each commit, extract the entity into a Map. - // 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. - async matchRolesToEntities( - invitation: XOInvitation, - entities: string[], - ): Promise> { - const entitiesMap = new Map>(); - for (const entity of entities) { - entitiesMap.set(entity, new Set()); + const reservedUtxosByInvitation = new Map(); + const standaloneUtxos: UtxoContext[] = []; + + for (const context of utxoContexts) { + const invitationIdentifier = context.utxo.reservedBy; + if (invitationIdentifier && invitationContexts.has(invitationIdentifier)) { + const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? []; + group.push(context); + reservedUtxosByInvitation.set(invitationIdentifier, group); + continue; + } + + standaloneUtxos.push(context); } - // 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) { - const entity = commit.entityIdentifier; - const roles = entitiesMap.get(entity) ?? new Set(); + const invitationItems = [...reservedUtxosByInvitation.entries()].map( + ([invitationIdentifier, reservedContexts]) => + this.projectInvitationHistory( + invitationContexts.get(invitationIdentifier)!, + reservedContexts, + ), + ); + const standaloneItems = standaloneUtxos.map((context) => + this.projectStandaloneUtxo(context), + ); + return [...standaloneItems, ...invitationItems].sort((a, b) => { + if (a.source !== b.source) return a.source === "utxo" ? -1 : 1; + return (b.createdAtTimestamp ?? 0) - (a.createdAtTimestamp ?? 0); + }); + } + + private async buildInvitationContextIndex(): Promise> { + const contexts = new Map(); + + for (const invitation of this.invitations) { + const template = + (await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? null; + contexts.set(invitation.data.invitationIdentifier, { + invitation, + template, + variables: this.extractInvitationVariables(invitation.data), + }); + } + + return contexts; + } + + private async buildWalletMetadataIndex( + allUtxos: UnspentOutputData[], + ): Promise { + const scriptHashDataByScriptHash = new Map(); + const templateIdentifiers = new Set(); + + for (const utxo of allUtxos) { + templateIdentifiers.add(utxo.templateIdentifier); + } + for (const invitation of this.invitations) { + templateIdentifiers.add(invitation.data.templateIdentifier); + } + + for (const templateIdentifier of templateIdentifiers) { + const scriptHashDataList = await this.engine.listScriptHashesForTemplate(templateIdentifier); + for (const scriptHashData of scriptHashDataList) { + scriptHashDataByScriptHash.set(scriptHashData.scriptHash, scriptHashData); + } + } + + return { scriptHashDataByScriptHash }; + } + + private async buildUtxoContext( + utxo: UnspentOutputData, + metadataIndex: WalletMetadataIndex, + ): Promise { + const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash); + const templateIdentifier = scriptHashData?.templateIdentifier ?? utxo.templateIdentifier; + const template = (await this.engine.getTemplate(templateIdentifier)) ?? null; + + return { + utxo, + scriptHashData, + template, + }; + } + + private projectInvitationHistory( + context: InvitationContext, + reservedContexts: UtxoContext[], + ): WalletHistoryItem { + const invitation = context.invitation.data; + const entityRoles = this.deriveInvitationEntityRoles(context); + const inputs = this.projectInvitationInputs(context, reservedContexts, entityRoles); + const inputUtxoIds = this.listInvitationInputUtxoIds(context, reservedContexts); + const outputs = this.projectInvitationOutputs( + context, + reservedContexts, + entityRoles, + inputUtxoIds, + ); + const roles = this.deriveRoles(inputs, outputs); + const valueSatoshis = this.calculateValueSatoshis(inputs, outputs); + + return { + id: `inv-${invitation.invitationIdentifier}`, + source: "invitation", + invitationIdentifier: invitation.invitationIdentifier, + createdAtTimestamp: invitation.createdAtTimestamp, + templateIdentifier: invitation.templateIdentifier, + template: context.template?.name ?? "UnknownTemplate", + action: invitation.actionIdentifier, + roles, + description: this.describeInvitation(context, roles[0]), + valueSatoshis, + inputs, + outputs, + }; + } + + private projectInvitationInputs( + context: InvitationContext, + reservedContexts: UtxoContext[], + entityRoles: Map, + ): WalletHistoryInput[] { + const invitation = context.invitation.data; + const inputs: WalletHistoryInput[] = []; + const reservedByOutpoint = new Map( + reservedContexts.map((context) => [ + this.getOutpointKey( + context.utxo.outpointTransactionHash, + context.utxo.outpointIndex, + ), + context, + ]), + ); + + for (const commit of invitation.commits) { + for (const [index, input] of (commit.data.inputs ?? []).entries()) { + const txid = this.getInputTxid(input); + const outpointIndex = input.outpointIndex; + if (txid === undefined || outpointIndex === undefined) continue; + + const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex)); + // TODO: Remove this reservation-based filter once Engine/library cleanup releases stale invitation reservations internally. + if (!utxoContext) continue; + + const inputIdentifier = input.inputIdentifier; + const role = + input.roleIdentifier ?? + this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ?? + utxoContext.scriptHashData?.roleIdentifier; + + inputs.push({ + id: `input-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${index}`, + commitIdentifier: commit.commitIdentifier, + inputIdentifier, + role, + description: this.describeInput(inputIdentifier, context), + valueSatoshis: -BigInt(utxoContext.utxo.valueSatoshis), + outpoint: { txid, index: outpointIndex }, + scriptHash: utxoContext.utxo.scriptHash, + }); + } + } + + return inputs; + } + + private projectInvitationOutputs( + context: InvitationContext, + reservedContexts: UtxoContext[], + entityRoles: Map, + inputUtxoIds: Set, + ): WalletHistoryOutput[] { + const invitation = context.invitation.data; + const outputs: WalletHistoryOutput[] = []; + const usedUtxoIds = new Set(); + + for (const commit of invitation.commits) { + for (const [index, output] of (commit.data.outputs ?? []).entries()) { + const matchingContext = this.findReservedOutputContext( + output, + reservedContexts, + usedUtxoIds, + ); + // UTXO-first: committed outputs only matter here if they resolve to a wallet UTXO currently reserved by this invitation. + if (!matchingContext) continue; + + const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode; + const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier ?? matchingContext.utxo.outputIdentifier; + const role = + output.roleIdentifier ?? + this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ?? + matchingContext.scriptHashData?.roleIdentifier; + const valueSatoshis = output.valueSatoshis !== undefined + ? BigInt(output.valueSatoshis) + : BigInt(matchingContext.utxo.valueSatoshis); + + usedUtxoIds.add(this.getUtxoId(matchingContext.utxo)); + + outputs.push({ + id: `output-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${index}`, + commitIdentifier: commit.commitIdentifier, + outputIdentifier, + role, + description: this.describeOutput(outputIdentifier, context), + valueSatoshis, + outpoint: { + txid: matchingContext.utxo.outpointTransactionHash, + index: matchingContext.utxo.outpointIndex, + }, + lockingBytecode, + scriptHash: matchingContext.utxo.scriptHash, + reserved: true, + }); + } + } + + for (const reservedContext of reservedContexts) { + if (usedUtxoIds.has(this.getUtxoId(reservedContext.utxo))) continue; + if (inputUtxoIds.has(this.getUtxoId(reservedContext.utxo))) continue; + outputs.push(this.projectUtxoOutput(reservedContext)); + } + + return outputs; + } + + private listInvitationInputUtxoIds( + context: InvitationContext, + reservedContexts: UtxoContext[], + ): Set { + const invitationInputUtxoIds = new Set(); + const reservedByOutpoint = new Map( + reservedContexts.map((context) => [ + this.getOutpointKey( + context.utxo.outpointTransactionHash, + context.utxo.outpointIndex, + ), + context, + ]), + ); + + for (const commit of context.invitation.data.commits) { + for (const input of commit.data.inputs ?? []) { + const txid = this.getInputTxid(input); + const outpointIndex = input.outpointIndex; + if (txid === undefined || outpointIndex === undefined) continue; + + const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex)); + if (utxoContext) invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo)); + } + } + + return invitationInputUtxoIds; + } + + private findReservedOutputContext( + output: XOInvitationOutput, + reservedContexts: UtxoContext[], + usedUtxoIds: Set, + ): UtxoContext | undefined { + const lockingBytecode = this.getOutputLockingBytecodeHex(output); + const scriptHash = lockingBytecode + ? this.lockingBytecodeToScriptHash(lockingBytecode) + : undefined; + + return reservedContexts.find((context) => { + if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false; + if (scriptHash && context.utxo.scriptHash === scriptHash) return true; + if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true; + if (output.outputIdentifier && context.utxo.outputIdentifier === output.outputIdentifier) return true; + return false; + }); + } + + private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem { + const output = this.projectUtxoOutput(context); + const templateIdentifier = context.scriptHashData?.templateIdentifier ?? context.utxo.templateIdentifier; + const role = output.role; + + return { + id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`, + source: "utxo", + templateIdentifier, + template: context.template?.name ?? "UnknownTemplate", + roles: role ? [role] : ["unknown"], + description: output.description, + valueSatoshis: output.valueSatoshis ?? 0n, + inputs: [], + outputs: [output], + }; + } + + private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput { + const outputIdentifier = context.scriptHashData?.outputIdentifier ?? context.utxo.outputIdentifier; + const role = context.scriptHashData?.roleIdentifier; + + return { + id: this.getUtxoId(context.utxo), + outputIdentifier, + role, + description: this.describeOutputFromTemplate(outputIdentifier, context.template, {}), + valueSatoshis: BigInt(context.utxo.valueSatoshis), + outpoint: { + txid: context.utxo.outpointTransactionHash, + index: context.utxo.outpointIndex, + }, + lockingBytecode: context.scriptHashData?.lockingBytecode, + scriptHash: context.utxo.scriptHash, + reserved: context.utxo.reservedBy !== undefined, + }; + } + + private deriveInvitationEntityRoles(context: InvitationContext): Map { + const invitation = context.invitation.data; + const rolesByEntity = new Map>(); + const allEntities = new Set(invitation.commits.map((commit) => commit.entityIdentifier)); + + for (const entityIdentifier of allEntities) { + rolesByEntity.set(entityIdentifier, new Set()); + } + + for (const commit of invitation.commits) { + const roles = rolesByEntity.get(commit.entityIdentifier) ?? new Set(); for (const input of commit.data.inputs ?? []) { if (input.roleIdentifier) roles.add(input.roleIdentifier); } @@ -110,329 +437,130 @@ export class HistoryService { for (const variable of commit.data.variables ?? []) { if (variable.roleIdentifier) roles.add(variable.roleIdentifier); } + rolesByEntity.set(commit.entityIdentifier, roles); } - // TODO: We might be able to use the lockingBytecodes that we have generated to infer which role we are. But the templates dont tell us which role is responsible for a particular output. - // I.e, if we dont know what role an output was from, we cant match it using the lockingBytecode to a role. - // Example: 2 inputs to a TX for the same amount. We dont know whether we would be Sender1 or Sender2. - // So, for now we are just going to rely on the roleIdentifiers that we have found in the first pass. - - // Format into a record for easier access. - const entitiesRecord: Record = {}; - for (const [entity, roles] of entitiesMap.entries()) { - entitiesRecord[entity] = Array.from(roles); + const action = context.template?.actions?.[invitation.actionIdentifier]; + const participantRoles = action?.requirements?.participants + ?.map((participant) => participant.role) + .filter((role): role is string => typeof role === "string") ?? []; + const explicitlyFilledRoles = new Set(); + for (const roles of rolesByEntity.values()) { + for (const role of roles) explicitlyFilledRoles.add(role); } - - return entitiesRecord; - } - - async getHistory(): Promise { - const allUtxos = await this.engine.listUnspentOutputsData(); - const invitationByOrigin = new Map(); - const outpointValueSatoshis = new Map(); - - for (const utxo of allUtxos) { - const outpointKey = this.getOutpointKey( - utxo.outpointTransactionHash, - utxo.outpointIndex, - ); - outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis)); - } - - const contexts = new Map(); - for (const invitation of this.invitations) { - const variables = this.extractInvitationVariables(invitation.data); - const template = - (await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? - null; - const walletCommits = await this.getWalletCommitsForInvitation( - invitation.data, - ); - const walletEntityIdentifier = walletCommits[0]?.entityIdentifier; - contexts.set(invitation.data.invitationIdentifier, { - invitation, - template, - variables, - walletCommits, - walletEntityIdentifier, - }); - this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation); - - // TODO: Remove or use this. Its a test for extracting the roles to entities. - // const entities = await this.extractEntities(invitation.data); - // const entitiesRecord = await this.matchRolesToEntities(invitation.data, entities); - // console.log(entitiesRecord); - } - - const usedUtxoIds = new Set(); - const invitationItems: HistoryInvitationItem[] = []; - - for (const context of contexts.values()) { - const invitation = context.invitation.data; - const templateName = context.template?.name ?? "UnknownTemplate"; - const invitationOutputs = this.buildWalletOutputItemsForInvitation( - context, - allUtxos, - invitationByOrigin, - usedUtxoIds, - ); - const roles = this.deriveWalletRolesForInvitation( - context, - invitationOutputs, - ); - const invitationInputs = this.buildWalletInputItemsForInvitation( - context, - roles[0], - outpointValueSatoshis, - ); - const invitationDescription = this.deriveInvitationDescription( - invitation, - context.template, - context.variables, - roles[0], - ); - - invitationItems.push({ - kind: "invitation", - id: `inv-${invitation.invitationIdentifier}`, - createdAtTimestamp: invitation.createdAtTimestamp, - templateIdentifier: invitation.templateIdentifier, - invitationIdentifier: invitation.invitationIdentifier, - roles, - description: invitationDescription, - descriptionParts: { - template: templateName, - roles, - description: invitationDescription, - }, - inputs: invitationInputs, - outputs: invitationOutputs, - }); - } - - invitationItems.sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp); - - const standaloneUtxos: HistoryUtxoItem[] = []; - for (const utxo of allUtxos) { - 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( - utxo, - description, - template?.name ?? "UnknownTemplate", - inferredRole, - "standalone", - ), - ); - } - - return [...invitationItems, ...standaloneUtxos]; - } - - private buildWalletOutputItemsForInvitation( - context: InvitationContext, - allUtxos: UnspentOutputData[], - invitationByOrigin: Map, - usedUtxoIds: Set, - ): HistoryUtxoItem[] { - const invitationId = context.invitation.data.invitationIdentifier; - const outputs: HistoryUtxoItem[] = []; - - for (const utxo of allUtxos) { - 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", - ), - ); - usedUtxoIds.add(this.getUtxoId(utxo)); - } - - return outputs; - } - - private async getWalletCommitsForInvitation( - invitation: XOInvitation, - ): Promise { - try { - return await this.engine.getOwnCommits(invitation); - } catch { - return []; - } - } - - private buildWalletInputItemsForInvitation( - context: InvitationContext, - walletRole?: string, - outpointValueSatoshis: Map = new Map(), - ): HistoryUtxoItem[] { - const invitation = context.invitation.data; - const relevantCommits = context.walletCommits.filter( - (commit) => (commit.data.inputs?.length ?? 0) > 0, - ); - const txDescription = this.deriveTransactionActivityDescription( - invitation, - context.template, - context.variables, - walletRole, + const unfilledParticipantRoles = participantRoles.filter( + (role) => !explicitlyFilledRoles.has(role), ); + const entitiesWithoutRoles = [...rolesByEntity.entries()] + .filter(([, roles]) => roles.size === 0) + .map(([entityIdentifier]) => entityIdentifier); - const inputs: HistoryUtxoItem[] = []; - 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"; - 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 = - this.deriveCommitRoleIdentifier(commit, invitation, context.template) ?? - walletRole ?? - "sender"; - const inputValue = this.resolveInputSatoshis( - txHash, - inputIndex, - outpointValueSatoshis, - context.variables, - ); - - inputs.push({ - kind: "utxo", - id: `input-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${txHash}:${inputIndex}-${inputIdentifier}`, - invitationIdentifier: invitation.invitationIdentifier, - templateIdentifier: invitation.templateIdentifier, - outputIdentifier: inputIdentifier, - outpoint: { - txid: txHash, - index: inputIndex, - }, - direction: "input", - valueSatoshis: inputValue, - description: `${txDescription} - ${inputDescription}`, - descriptionParts: { - template: templateName, - role, - outputIdentifier: inputIdentifier, - description: `${txDescription} - ${inputDescription}`, - valueSatoshis: - inputValue !== undefined ? Number(inputValue) : undefined, - }, - }); + if (unfilledParticipantRoles.length === 1 && entitiesWithoutRoles.length >= 1) { + const inferredRole = unfilledParticipantRoles[0]; + if (inferredRole !== undefined) { + for (const entityIdentifier of entitiesWithoutRoles) { + rolesByEntity.get(entityIdentifier)?.add(inferredRole); + } } } - return inputs; + return new Map( + [...rolesByEntity.entries()].map(([entityIdentifier, roles]) => [ + entityIdentifier, + [...roles], + ]), + ); } - private buildUtxoHistoryItem( - utxo: UnspentOutputData, - description: string, - templateName: string, - roleIdentifier: string | undefined, - direction: HistoryUtxoItem["direction"], - ): HistoryUtxoItem { - return { - kind: "utxo", - id: this.getUtxoId(utxo), - invitationIdentifier: utxo.reservedBy || undefined, - templateIdentifier: utxo.templateIdentifier, - outputIdentifier: utxo.outputIdentifier, - outpoint: { - txid: utxo.outpointTransactionHash, - index: utxo.outpointIndex, - }, - valueSatoshis: BigInt(utxo.valueSatoshis), - reserved: utxo.reservedBy ? true : false, - direction, - description, - descriptionParts: { - template: templateName, - role: roleIdentifier ?? "unknown", - outputIdentifier: utxo.outputIdentifier, - description, - valueSatoshis: utxo.valueSatoshis, - }, - }; + private getFirstEntityRole( + entityRoles: Map, + entityIdentifier: string, + ): string | undefined { + return entityRoles.get(entityIdentifier)?.[0]; } - private deriveWalletRolesForInvitation( - context: InvitationContext, - outputs: HistoryUtxoItem[], + private deriveRoles( + inputs: WalletHistoryInput[], + outputs: WalletHistoryOutput[], ): string[] { const roles = new Set(); + + for (const input of inputs) { + if (input.role) roles.add(input.role); + } for (const output of outputs) { - const outputRole = output.descriptionParts.role; - if (outputRole && outputRole !== "unknown") { - roles.add(outputRole); - } - } - if (roles.size === 0 && outputs.length > 0) { - roles.add("receiver"); + if (output.role) roles.add(output.role); } - for (const commit of context.walletCommits) { - const role = this.deriveCommitRoleIdentifier( - commit, - context.invitation.data, - context.template, - ); - if (role) roles.add(role); + return roles.size > 0 ? [...roles] : ["unknown"]; + } + + private calculateValueSatoshis( + inputs: WalletHistoryInput[], + outputs: WalletHistoryOutput[], + ): bigint { + const inputTotal = inputs.reduce((total, input) => total + (input.valueSatoshis ?? 0n), 0n); + const outputTotal = outputs.reduce((total, output) => total + (output.valueSatoshis ?? 0n), 0n); + return inputTotal + outputTotal; + } + + private describeInvitation(context: InvitationContext, role?: string): string { + const invitation = context.invitation.data; + const template = context.template; + if (!template) return invitation.actionIdentifier; + + const action = template.actions?.[invitation.actionIdentifier]; + const transaction = action?.transaction + ? template.transactions?.[action.transaction] + : undefined; + const roleData = role ? transaction?.roles?.[role] : undefined; + const descriptionTemplate = + roleData?.description ?? + transaction?.description ?? + roleData?.name ?? + transaction?.name ?? + action?.description ?? + action?.name ?? + invitation.actionIdentifier; + + return this.compileDescription(descriptionTemplate, context.variables); + } + + private describeInput(inputIdentifier: string | undefined, context: InvitationContext): string { + if (!inputIdentifier) return "Input"; + const input = context.template?.inputs?.[inputIdentifier]; + return this.compileDescription(input?.description ?? input?.name ?? inputIdentifier, context.variables); + } + + private describeOutput(outputIdentifier: string | undefined, context: InvitationContext): string { + return this.describeOutputFromTemplate(outputIdentifier, context.template, context.variables); + } + + private describeOutputFromTemplate( + outputIdentifier: string | undefined, + template: XOTemplate | null, + variables: Record, + ): string { + if (!outputIdentifier) return "Output"; + const output = template?.outputs?.[outputIdentifier]; + return this.compileDescription(output?.description ?? output?.name ?? outputIdentifier, variables); + } + + private compileDescription( + description: string, + variables: Record, + ): string { + try { + return compileCashAssemblyString(description, variables); + } catch { + return this.interpolateSimpleCashAssemblyVariables(description, variables); } - - const hasInputCommit = context.walletCommits.some( - (c) => (c.data.inputs?.length ?? 0) > 0, - ); - if (hasInputCommit) roles.add("sender"); - - return roles.size > 0 ? Array.from(roles) : ["unknown"]; } private extractInvitationVariables( invitation: XOInvitation, ): Record { - const committedVariables = invitation.commits.flatMap( - (c) => c.data.variables ?? [], - ); + const committedVariables = invitation.commits.flatMap((c) => c.data.variables ?? []); return committedVariables.reduce( (acc, variable) => { if (!variable.variableIdentifier) return acc; @@ -443,235 +571,31 @@ export class HistoryService { ); } - private indexInvitationOutputsByUtxoOrigin( - invitationByUtxoOrigin: Map, - 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, - ); - invitationByUtxoOrigin.set(key, { - invitationIdentifier: invitation.data.invitationIdentifier, - roleIdentifier: output.roleIdentifier, - }); - } - } + private getInputTxid(input: XOInvitationInput): string | undefined { + if (!input.outpointTransactionHash) return undefined; + return input.outpointTransactionHash instanceof Uint8Array + ? binToHex(input.outpointTransactionHash) + : String(input.outpointTransactionHash); } - private resolveInvitationIdentifierForUtxo( - utxo: UnspentOutputData, - invitationByUtxoOrigin: Map, - ): string | undefined { - if (utxo.reservedBy) return utxo.reservedBy; - const originKey = this.getUtxoOriginKey( - utxo.templateIdentifier, - utxo.outputIdentifier, - utxo.scriptHash, - ); - return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier; - } - - private resolveRoleIdentifierForUtxo( - utxo: UnspentOutputData, - invitationByUtxoOrigin: Map, - ): string | undefined { - const originKey = this.getUtxoOriginKey( - utxo.templateIdentifier, - utxo.outputIdentifier, - utxo.scriptHash, - ); - return invitationByUtxoOrigin.get(originKey)?.roleIdentifier; - } - - private deriveUtxoDescription( - utxo: UnspentOutputData, - template: XOTemplate | null, - variables: Record, - roleIdentifier?: string, - ): string { - 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, - ); - } - } - return `[${templateName}:${role}] ${detail}`; - } - - private deriveInvitationDescription( - invitation: XOInvitation, - template: XOTemplate | null, - variables: Record, - 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; - let detail = baseTemplate; - try { - detail = compileCashAssemblyString(baseTemplate, variables); - } catch { - detail = this.interpolateSimpleCashAssemblyVariables( - baseTemplate, - variables, - ); - } - return `[${template.name}:${role}] ${detail}`; - } - - private deriveInputDescription( - inputIdentifier: string, - template: XOTemplate | null, - variables: Record, - ): string { - 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, - ); - } - } - - private deriveTransactionActivityDescription( - invitation: XOInvitation, - template: XOTemplate | null, - variables: Record, - 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; - try { - return compileCashAssemblyString(descriptionTemplate, variables); - } catch { - return this.interpolateSimpleCashAssemblyVariables( - descriptionTemplate, - variables, - ); - } - } - - private deriveCommitRoleIdentifier( - commit: XOInvitationCommit, - invitation: XOInvitation, - template: XOTemplate | null, - ): string | undefined { - const explicitRoles = new Set(); - for (const input of commit.data.inputs ?? []) { - if (input.roleIdentifier) explicitRoles.add(input.roleIdentifier); - } - for (const output of commit.data.outputs ?? []) { - if (output.roleIdentifier) explicitRoles.add(output.roleIdentifier); - } - for (const variable of commit.data.variables ?? []) { - if (variable.roleIdentifier) explicitRoles.add(variable.roleIdentifier); - } - 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"; - return 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"; - return undefined; - } - - private resolveInputSatoshis( - txHash: string, - index: number, - outpointValueSatoshis: Map, - variables: Record, - ): bigint | undefined { - const outpointKey = this.getOutpointKey(txHash, index); - const matchedValue = outpointValueSatoshis.get(outpointKey); - if (matchedValue !== undefined) return matchedValue; - - const requestedSatoshis = variables.requestedSatoshis; - if (requestedSatoshis !== undefined) { - try { - return BigInt(String(requestedSatoshis)); - } catch { - return undefined; - } - } - - return undefined; - } - - private getUtxoId(utxo: UnspentOutputData): string { - return `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`; + private getOutputLockingBytecodeHex(output: XOInvitationOutput): string | undefined { + if (output.lockingBytecode === undefined) return undefined; + return typeof output.lockingBytecode === "string" + ? output.lockingBytecode + : binToHex(output.lockingBytecode); } private getOutpointKey(txid: string, index: number): string { return `${txid}:${index}`; } - private getUtxoOriginKey( - templateIdentifier: string, - outputIdentifier: string, - lockingBytecodeHex: string, - ): string { - return `${templateIdentifier}:${outputIdentifier}:${lockingBytecodeHex}`; + private getUtxoId(utxo: UnspentOutputData): string { + return `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`; } - private toLockingBytecodeHex(lockingBytecode: string | Uint8Array): string { - if (typeof lockingBytecode === "string") return lockingBytecode; - return binToHex(lockingBytecode); + private lockingBytecodeToScriptHash(lockingBytecode: string): string { + const hash = sha256.hash(hexToBin(lockingBytecode)); + return binToHex(hash.reverse()); } private interpolateSimpleCashAssemblyVariables( @@ -681,10 +605,9 @@ export class HistoryService { return text.replace( /\$\(<([^>]+)>\)/g, (match, variableIdentifier: string) => { - if ( - !Object.prototype.hasOwnProperty.call(variables, variableIdentifier) - ) + if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) { return match; + } return String(variables[variableIdentifier]); }, ); diff --git a/src/tui/screens/WalletState.tsx b/src/tui/screens/WalletState.tsx index 582790c..4ceaa4f 100644 --- a/src/tui/screens/WalletState.tsx +++ b/src/tui/screens/WalletState.tsx @@ -350,29 +350,32 @@ export function WalletStateScreen(): React.ReactElement { const indicator = isFocused ? '▸ ' : ' '; const groupingPrefix = row.isNested ? ' -> ' : ''; - if (row.type === 'invitation') { + if (row.type === 'history_item') { + const sats = row.valueSatoshis ?? 0n; + const fiatSuffix = getFiatSuffix(sats); return ( - - {indicator}[Invitation] {row.label} - + + + {indicator}{formatSatoshis(sats)}{fiatSuffix} + + {row.label} + {dateStr && {dateStr}} ); } - if (row.type === 'invitation_input') { - const inputSatoshis = row.utxo?.valueSatoshis; - const inputFiatSuffix = inputSatoshis !== undefined - ? getFiatSuffix(inputSatoshis) - : ''; + if (row.type === 'history_input') { + const sats = row.valueSatoshis ?? 0n; return ( - {indicator}{groupingPrefix}[Input] {row.label} - {inputFiatSuffix} + {indicator}{groupingPrefix}[Input] {formatSatoshis(sats)} + {getFiatSuffix(sats)} + {row.label} {row.description && {row.description}} {dateStr && {dateStr}} @@ -380,8 +383,9 @@ export function WalletStateScreen(): React.ReactElement { ); } - if (row.type === 'invitation_output') { - const sats = row.utxo?.valueSatoshis ?? 0n; + if (row.type === 'history_output') { + const sats = row.valueSatoshis ?? 0n; + const reservedTag = row.reserved ? ' [Reserved]' : ''; return ( @@ -389,6 +393,7 @@ export function WalletStateScreen(): React.ReactElement { {indicator}{groupingPrefix}[Output] {formatSatoshis(sats)} {getFiatSuffix(sats)} + {row.label}{reservedTag} {row.description && {row.description}} {dateStr && {dateStr}} @@ -396,23 +401,6 @@ export function WalletStateScreen(): React.ReactElement { ); } - if (row.type === 'utxo') { - const sats = row.utxo?.valueSatoshis ?? 0n; - const reservedTag = row.utxo?.reserved ? ' [Reserved]' : ''; - return ( - - - - {indicator}{formatSatoshis(sats)} - {getFiatSuffix(sats)} - - {row.description && {row.description}{reservedTag}} - - {dateStr && {dateStr}} - - ); - } - // Fallback for other types return ( @@ -515,7 +503,7 @@ export function WalletStateScreen(): React.ReactElement { height={14} overflow="hidden" > - Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''} + Wallet History {historyListItems.length > 0 ? `(${selectedHistoryIndex + 1}/${historyListItems.length})` : ''} {isLoading ? ( Loading... diff --git a/src/tui/screens/invitations/InvitationScreen.tsx b/src/tui/screens/invitations/InvitationScreen.tsx index 593da0d..b82684d 100644 --- a/src/tui/screens/invitations/InvitationScreen.tsx +++ b/src/tui/screens/invitations/InvitationScreen.tsx @@ -21,7 +21,7 @@ import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js'; import { colors, logoSmall, formatSatoshis } from '../../theme.js'; import { copyToClipboard } from '../../utils/clipboard.js'; import type { Invitation } from '../../../services/invitation.js'; -import type { XOTemplate } from '@xo-cash/types'; +import type { XOInvitationCommit, XOTemplate } from '@xo-cash/types'; import { getInvitationState, @@ -29,7 +29,6 @@ import { getInvitationInputs, getInvitationOutputs, getInvitationVariables, - getUserRole, formatInvitationListItem, formatInvitationId, } from '../../../utils/invitation-utils.js'; @@ -80,6 +79,29 @@ const invitationListGroups: ListGroup[] = [ { id: 'invitations', separator: true }, ]; +type OwnInvitationContext = { + entityIdentifier: string | null; + roleIdentifier: string | null; +}; + +function getRoleIdentifierFromCommits(commits: XOInvitationCommit[]): string | null { + for (const commit of commits) { + for (const input of commit.data.inputs ?? []) { + if (input.roleIdentifier) return input.roleIdentifier; + } + + for (const output of commit.data.outputs ?? []) { + if (output.roleIdentifier) return output.roleIdentifier; + } + + for (const variable of commit.data.variables ?? []) { + if (variable.roleIdentifier) return variable.roleIdentifier; + } + } + + return null; +} + /** * Invitation Screen Component. */ @@ -107,6 +129,10 @@ export function InvitationScreen(): React.ReactElement { // ── Template cache ─────────────────────────────────────────────────────── const [templateCache, setTemplateCache] = useState>(new Map()); const [selectedTemplate, setSelectedTemplate] = useState(null); + const [ownInvitationContext, setOwnInvitationContext] = useState({ + entityIdentifier: null, + roleIdentifier: null, + }); // Check if we should open import dialog on mount const initialMode = navData.mode as string | undefined; @@ -180,6 +206,43 @@ export function InvitationScreen(): React.ReactElement { .then(template => setSelectedTemplate(template ?? null)); }, [selectedInvitation, appService]); + /** + * Load the current engine entity's commits for the selected invitation. + */ + useEffect(() => { + if (!selectedInvitation || !appService) { + setOwnInvitationContext({ + entityIdentifier: null, + roleIdentifier: null, + }); + return; + } + + let isCurrent = true; + + appService.engine.getOwnCommits(selectedInvitation.data) + .then((ownCommits) => { + if (!isCurrent) return; + + setOwnInvitationContext({ + entityIdentifier: ownCommits[0]?.entityIdentifier ?? null, + roleIdentifier: getRoleIdentifierFromCommits(ownCommits), + }); + }) + .catch(() => { + if (!isCurrent) return; + + setOwnInvitationContext({ + entityIdentifier: null, + roleIdentifier: null, + }); + }); + + return () => { + isCurrent = false; + }; + }, [selectedInvitation, appService]); + // ── Import flow callbacks ────────────────────────────────────────────── /** @@ -512,9 +575,8 @@ export function InvitationScreen(): React.ReactElement { const inputs = getInvitationInputs(selectedInvitation); const outputs = getInvitationOutputs(selectedInvitation); const variables = getInvitationVariables(selectedInvitation); - - const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null; - const userRole = getUserRole(selectedInvitation, userEntityId); + const userEntityId = ownInvitationContext.entityIdentifier; + const userRole = ownInvitationContext.roleIdentifier; const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole]; const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; diff --git a/src/utils/history-utils.ts b/src/utils/history-utils.ts index bf45fb8..2b727cc 100644 --- a/src/utils/history-utils.ts +++ b/src/utils/history-utils.ts @@ -1,7 +1,8 @@ import type { HistoryItem, - HistoryInvitationItem, - HistoryUtxoItem, + WalletHistoryInput, + WalletHistoryItem, + WalletHistoryOutput, } from "../services/history.js"; export type HistoryColorName = @@ -13,10 +14,9 @@ export type HistoryColorName = | "text"; export type HistoryRowType = - | "invitation" - | "invitation_input" - | "invitation_output" - | "utxo"; + | "history_item" + | "history_input" + | "history_output"; export interface HistoryDisplayRow { id: string; @@ -25,8 +25,11 @@ export interface HistoryDisplayRow { description?: string; timestamp?: number; isNested: boolean; - utxo?: HistoryUtxoItem; - invitation?: HistoryInvitationItem; + valueSatoshis?: bigint; + reserved?: boolean; + input?: WalletHistoryInput; + output?: WalletHistoryOutput; + item?: WalletHistoryItem; } export function formatHistoryDate(timestamp?: number): string | undefined { @@ -40,61 +43,68 @@ export function buildHistoryDisplayRows( const rows: HistoryDisplayRow[] = []; for (const item of items) { - if (item.kind === "invitation") { - rows.push({ - id: item.id, - type: "invitation", - label: item.description, - timestamp: item.createdAtTimestamp, - isNested: false, - invitation: item, - }); - - for (const input of item.inputs) { - const satsPrefix = - input.valueSatoshis !== undefined - ? `${input.valueSatoshis.toLocaleString()} sats ` - : ""; - rows.push({ - id: `${item.id}-input-${input.id}`, - type: "invitation_input", - label: `${satsPrefix}${input.outpoint.txid}:${input.outpoint.index}`, - description: input.description, - isNested: true, - utxo: input, - invitation: item, - }); - } - + const roles = item.roles.length > 0 ? item.roles.join(", ") : "unknown"; + if (item.source === "utxo") { for (const output of item.outputs) { rows.push({ id: `${item.id}-output-${output.id}`, - type: "invitation_output", - label: - output.valueSatoshis !== undefined - ? `${output.valueSatoshis.toLocaleString()} sats` - : "Output", - description: output.description, - isNested: true, - utxo: output, - invitation: item, + type: "history_output", + label: output.outpoint + ? `${output.outpoint.txid}:${output.outpoint.index}` + : output.outputIdentifier ?? "Output", + description: `${item.template} | ${roles} | ${output.description}`, + timestamp: item.createdAtTimestamp, + isNested: false, + valueSatoshis: output.valueSatoshis, + reserved: output.reserved, + output, + item, }); } - continue; } rows.push({ id: item.id, - type: "utxo", - label: - item.valueSatoshis !== undefined - ? `${item.valueSatoshis.toLocaleString()} sats` - : "UTXO", - description: item.description, + type: "history_item", + label: `${item.template} | ${roles} | ${item.description}`, + description: item.action, + timestamp: item.createdAtTimestamp, isNested: false, - utxo: item, + valueSatoshis: item.valueSatoshis, + item, }); + + if (item.source !== "invitation") continue; + + for (const input of item.inputs) { + rows.push({ + id: `${item.id}-input-${input.id}`, + type: "history_input", + label: `${input.outpoint.txid}:${input.outpoint.index}`, + description: input.description, + isNested: true, + valueSatoshis: input.valueSatoshis, + input, + item, + }); + } + + for (const output of item.outputs) { + rows.push({ + id: `${item.id}-output-${output.id}`, + type: "history_output", + label: output.outpoint + ? `${output.outpoint.txid}:${output.outpoint.index}` + : output.outputIdentifier ?? "Output", + description: output.description, + isNested: true, + valueSatoshis: output.valueSatoshis, + reserved: output.reserved, + output, + item, + }); + } } return rows; @@ -106,14 +116,14 @@ export function getHistoryItemColorName( ): HistoryColorName { if (isSelected) return "info"; switch (row.type) { - case "invitation": - return "text"; - case "invitation_input": + case "history_input": return "error"; - case "invitation_output": - return "success"; - case "utxo": - return row.utxo?.reserved ? "warning" : "success"; + case "history_output": + return row.reserved ? "warning" : "success"; + case "history_item": + if ((row.valueSatoshis ?? 0n) < 0n) return "error"; + if ((row.valueSatoshis ?? 0n) > 0n) return "success"; + return "text"; default: return "text"; }