import { Hash, Hex } from 'ox' import * as Store from './Store.js' import * as Ttl from './Ttl.js' /** * The 4-byte tag that prefixes every MPP attribution memo. A `TransferWithMemo` * whose memo starts with this tag encodes the paying service's server * fingerprint rather than human-readable text, so the memo is suppressed from * the UI and decoded into a service name instead. */ const attributionTag = '0xef1ed712' /** The live MPP service directory endpoint. */ const directoryUrl = 'https://mpp.dev/api/services' /** * Whether a `TransferWithMemo` memo is an MPP attribution memo: a full 32-byte * value prefixed with the {@link attributionTag}. */ export function isAttributionMemo(memo: Hex.Hex): boolean { return memo.length === 66 && memo.toLowerCase().startsWith(attributionTag) } /** * Extracts the 10-byte MPP server fingerprint from an attribution memo, or * undefined when the memo is not an attribution memo. The fingerprint occupies * bytes 5–14 (after the 4-byte tag and a 1-byte version), matching the * `0x`-prefixed lowercase hex keys produced by {@link fingerprintForRealm}. */ export function extractFingerprint(memo: Hex.Hex): string | undefined { if (!isAttributionMemo(memo)) return undefined return `0x${memo.slice(12, 32).toLowerCase()}` } /** * Computes the MPP server fingerprint for a service realm: the first 10 bytes * of `keccak256(realm)`, lowercased. The directory and the memo agree on this * derivation, so a memo fingerprint can be matched against the directory map. */ export function fingerprintForRealm(realm: string): string { const hash = Hash.keccak256(Hex.fromString(realm), { as: 'Hex' }) return Hex.slice(hash, 0, 10).toLowerCase() } /** * Resolves the service display name for a transfer from its attribution memo: * the live directory entry for the memo's fingerprint, falling back to the * generic `MPP Service` label when the memo is an attribution memo whose * fingerprint is not (yet) in the directory. Returns undefined when there is no * attribution memo (e.g. inbound transfers reconstructed without log data, or * plain non-MPP transfers). */ export function resolve(options: resolve.Options): string | undefined { const { fingerprintMap, memo } = options if (!memo || !fingerprintMap || !isAttributionMemo(memo)) return undefined const fingerprint = extractFingerprint(memo) if (fingerprint) { const name = fingerprintMap[fingerprint] if (name) return name } return 'MPP Service' } export declare namespace resolve { /** Options for {@link resolve}. */ type Options = { /** Fingerprint → service-name directory map, when available. */ fingerprintMap?: Record | undefined /** Raw `TransferWithMemo` memo, when the transfer carried one. */ memo?: Hex.Hex | undefined } } /** * Fetches and memoizes the live MPP directory as a fingerprint → service-name * map, so attribution memos can be resolved to current service names. The fetch * is bounded by a short timeout; only successful results are cached (failures * propagate so the next call retries rather than caching an empty map). */ export async function fingerprintMap( options: fingerprintMap.Options, ): Promise> { const { store, ttl = Ttl.minutes(10), url = directoryUrl } = options return Store.memoize( async () => { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 1_000) try { const response = await fetch(url, { headers: { accept: 'application/json' }, signal: controller.signal, }) if (!response.ok) throw new UpstreamError(response.status) const data = (await response.json()) as fingerprintMap.Directory const map: Record = {} for (const service of data.services) { // A service without an explicit realm (or the shared default realm) // derives its realm from its id under the canonical MPP domain. const realm = service.realm && service.realm !== 'mpp.tempo.xyz' ? service.realm : `${service.id}.mpp.tempo.xyz` map[fingerprintForRealm(realm)] = service.name } return map } finally { clearTimeout(timeout) } }, { key: 'mpp-services:v1', store, ttl }, ) } export declare namespace fingerprintMap { /** Options for {@link fingerprintMap}. */ type Options = { /** Cache store for directory memoization. */ store: Store.Store /** Cache TTL in milliseconds (defaults to 10 minutes). */ ttl?: number | undefined /** Override for the directory endpoint (defaults to the live MPP directory). */ url?: string | undefined } /** Shape of the MPP directory response. */ type Directory = { services: readonly { id: string; name: string; realm?: string | undefined }[] } } /** Thrown when the MPP directory endpoint returns a non-2xx status. */ export class UpstreamError extends Error { /** The HTTP status returned by the directory endpoint. */ status: number constructor(status: number) { super(`MPP directory request failed with status ${status}`) this.name = 'Mpp.UpstreamError' this.status = status } }