From 82f008b33f0843b8c999d26f1f7d4dd62e6c45b2 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:55:47 +0200 Subject: [PATCH] feat: validate trusted mint registry envelopes --- src/lnurlcash/trustedMintMerge.ts | 43 +++++++++++++ src/lnurlcash/trustedMintsRegistry.ts | 88 +++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/lnurlcash/trustedMintMerge.ts create mode 100644 src/lnurlcash/trustedMintsRegistry.ts diff --git a/src/lnurlcash/trustedMintMerge.ts b/src/lnurlcash/trustedMintMerge.ts new file mode 100644 index 0000000..8909b25 --- /dev/null +++ b/src/lnurlcash/trustedMintMerge.ts @@ -0,0 +1,43 @@ +// Hostile backup merge policy stays separate from live mint transitions: +// local records win, and imported trust starts unlocked and unconfirmed. + +import type {TrustedMint} from './trustedMints' +import {isJsonObject} from './jsonParsing' +import {isValidMintPubkey, type MintTransition} from './trustedMintTransitions' + +export const mergeMints = (mints: TrustedMint[], incoming: unknown[]): MintTransition => { + const knownServers = new Set(mints.map((mint) => mint.server)) + const merged = [...mints] + let added = 0 + for (const mint of incoming) { + if ( + !isJsonObject(mint) || + typeof mint.server !== 'string' || + typeof mint.mintPubkey !== 'string' || + typeof mint.addedAt !== 'number' || + !isValidMintPubkey(mint.mintPubkey) + ) { + continue + } + if (knownServers.has(mint.server)) continue + merged.push({ + server: mint.server, + mintPubkey: mint.mintPubkey.toLowerCase(), + addedAt: mint.addedAt, + locked: false, + unconfirmed: true, + nodeAlias: typeof mint.nodeAlias === 'string' ? mint.nodeAlias : undefined, + nodeColor: typeof mint.nodeColor === 'string' ? mint.nodeColor : undefined, + nodeCapacityMsat: + typeof mint.nodeCapacityMsat === 'number' ? mint.nodeCapacityMsat : undefined, + nodeNumChannels: typeof mint.nodeNumChannels === 'number' ? mint.nodeNumChannels : undefined, + nodeNumPeers: typeof mint.nodeNumPeers === 'number' ? mint.nodeNumPeers : undefined, + username: typeof mint.username === 'string' ? mint.username : undefined, + }) + knownServers.add(mint.server) + added++ + } + return added === 0 + ? {mints, result: 0, changed: false} + : {mints: merged, result: added, changed: true} +} diff --git a/src/lnurlcash/trustedMintsRegistry.ts b/src/lnurlcash/trustedMintsRegistry.ts new file mode 100644 index 0000000..99988ef --- /dev/null +++ b/src/lnurlcash/trustedMintsRegistry.ts @@ -0,0 +1,88 @@ +// Strict trusted-mint envelope parsing is shared by localStorage and the +// durable cross-context commit mirror. Either source fails closed as a whole. + +import {isWalletOwnerId} from './storage/walletOwner' +import type {TrustedMint} from './trustedMints' +import {isValidMintPubkey} from './trustedMintTransitions' + +export const TRUSTED_MINTS_REGISTRY_VERSION = 1 + +export type TrustedMintsRegistryEnvelope = { + readonly version: typeof TRUSTED_MINTS_REGISTRY_VERSION + readonly ownerId: string + readonly mints: TrustedMint[] +} + +export type StoredTrustedMintsRegistry = + | {readonly kind: 'absent'} + | {readonly kind: 'malformed'} + | {readonly kind: 'valid'; readonly envelope: TrustedMintsRegistryEnvelope} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const isOptionalString = (value: unknown): boolean => + value === undefined || typeof value === 'string' + +const isOptionalNumber = (value: unknown): boolean => + value === undefined || typeof value === 'number' + +const isTrustedMint = (value: unknown): value is TrustedMint => { + if (!isRecord(value)) return false + return ( + typeof value.server === 'string' && + typeof value.mintPubkey === 'string' && + isValidMintPubkey(value.mintPubkey) && + typeof value.addedAt === 'number' && + typeof value.locked === 'boolean' && + (value.unconfirmed === undefined || typeof value.unconfirmed === 'boolean') && + (value.pendingMintPubkey === undefined || + (typeof value.pendingMintPubkey === 'string' && + isValidMintPubkey(value.pendingMintPubkey))) && + isOptionalString(value.nodeAlias) && + isOptionalString(value.nodeColor) && + isOptionalNumber(value.nodeCapacityMsat) && + isOptionalNumber(value.nodeNumChannels) && + isOptionalNumber(value.nodeNumPeers) && + isOptionalString(value.username) + ) +} + +export const parseStoredTrustedMintsRegistry = (raw: string | null): StoredTrustedMintsRegistry => { + if (raw === null) return {kind: 'absent'} + try { + const parsed: unknown = JSON.parse(raw) + if ( + !isRecord(parsed) || + parsed.version !== TRUSTED_MINTS_REGISTRY_VERSION || + !isWalletOwnerId(parsed.ownerId) || + !Array.isArray(parsed.mints) || + !parsed.mints.every(isTrustedMint) + ) { + return {kind: 'malformed'} + } + return { + kind: 'valid', + envelope: { + version: TRUSTED_MINTS_REGISTRY_VERSION, + ownerId: parsed.ownerId, + mints: parsed.mints, + }, + } + } catch { + return {kind: 'malformed'} + } +} + +export const parseLegacyTrustedMintsRegistry = (raw: string | null): TrustedMint[] | null => { + if (raw === null) return null + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) && parsed.every(isTrustedMint) ? parsed : null + } catch { + return null + } +} + +export const serializeTrustedMintsRegistry = (ownerId: string, mints: TrustedMint[]): string => + JSON.stringify({version: TRUSTED_MINTS_REGISTRY_VERSION, ownerId, mints})