diff --git a/unfurler/src/canvas/components.ts b/unfurler/src/canvas/components.ts index 7545bc96b..fe730f49a 100644 --- a/unfurler/src/canvas/components.ts +++ b/unfurler/src/canvas/components.ts @@ -5,6 +5,7 @@ import { IEsploraApi } from "../api/esplora-api.interface"; import { fetchJSON } from "../api/api"; import { getImage } from "./images"; import { renderBlockViz } from "./block-viz/block-viz"; +import { renderTxBowtie } from "./tx-bowtie/tx-bowtie"; import { themes } from "./themes"; import { CanvasRenderingContext2D, Image } from 'canvas'; import { formatNumber, formatWeightUnit, middleEllipsis, renderQrToCtx } from "./utils/utils"; @@ -65,7 +66,14 @@ export const dataRequirements: Record DataRequirement => ({ + key: `tx_${txid}`, + fetcher: async () => { + return await fetchJSON(config.API.ESPLORA + `/tx/${txid}`) as IEsploraApi.Transaction; + } + }), + extendedBlock: (id: string): DataRequirement => ({ key: `extended_block_${id}`, fetcher: async () => { @@ -272,6 +280,185 @@ export const components: Record Component> = { } }), + txContent: (txid: string, position: Position): Component => { + const bounds = { ...position, w: position.w ?? 1200, h: position.h ?? 520 } + const boxPadding = 10; + const boxHeight = 366; + const boxMargin = { left: 48, right: 48, bottom: 24 }; + const boxX = bounds.x + boxMargin.left; + const boxY = bounds.y + bounds.h - boxMargin.bottom - boxHeight; + const boxW = bounds.w - boxMargin.left - boxMargin.right; + + return { + type: 'tx', + data: [ + dataRequirements.tx(txid), + ], + children: [ + components.txViz(txid, { + x: boxX + boxPadding, + y: boxY + boxPadding, + w: boxW - (boxPadding * 2), + h: boxHeight - (boxPadding * 2) + }), + ], + render: async (ctx: CanvasRenderingContext2D, data: any): Promise => { + const tx = data[`tx_${txid}`]; + const totalValue = tx.vout.reduce((acc: number, v: any) => (v.value || 0) + acc, 0); + const isCoinbase = tx.vin.some((v: any) => v.is_coinbase === true); + + ctx.fillStyle = '#181b2d'; + ctx.fillRect(boxX, boxY, boxW, boxHeight); + + ctx.font = 'bold 50px Roboto'; + ctx.fillStyle = themes.default.fg; + ctx.textAlign = 'left'; + const txidWidth = bounds.w - 96; + ctx.fillText(middleEllipsis(ctx, txid, txidWidth), bounds.x + 48, bounds.y + 50); + + const secondRowY = boxY - 20; + ctx.font = 'bold 32px Roboto'; + ctx.fillStyle = themes.default.fg; + ctx.textAlign = 'left'; + const amountNum = formatNumber(totalValue / 1e8, '1.8-8'); + ctx.fillText(amountNum, bounds.x + 48, secondRowY); + const amountWidth = ctx.measureText(amountNum).width; + ctx.font = '24px Roboto'; + ctx.fillStyle = themes.default.symbol; + ctx.fillText('BTC', bounds.x + 48 + amountWidth + 6, secondRowY + 2); + + if (tx.status?.block_time) { + ctx.font = 'bold 32px Roboto'; + ctx.fillStyle = themes.default.fg; + ctx.textAlign = 'center'; + const datetime = new Date(tx.status.block_time * 1000).toLocaleString('sv-SE').replace(',', '').slice(0, 16); + ctx.fillText(datetime, bounds.x + bounds.w / 2, secondRowY); + } + + ctx.font = 'bold 32px Roboto'; + ctx.fillStyle = themes.default.fg; + ctx.textAlign = 'right'; + const feeNum = formatNumber(tx.fee, '1.0-0'); + const feeNumWidth = ctx.measureText(feeNum).width; + ctx.font = '24px Roboto'; + ctx.fillStyle = themes.default.symbol; + const unitWidth = ctx.measureText('sats').width; + ctx.fillText('Fee ', bounds.x + bounds.w - 48 - feeNumWidth - 6 - unitWidth - 6, secondRowY + 2); + ctx.font = 'bold 32px Roboto'; + ctx.fillStyle = themes.default.fg; + ctx.fillText(feeNum, bounds.x + bounds.w - 48 - unitWidth - 6, secondRowY); + ctx.font = '24px Roboto'; + ctx.fillStyle = themes.default.symbol; + ctx.fillText('sats', bounds.x + bounds.w - 48, secondRowY + 2); + + const statsY = boxY + 42; + const statsX = boxX + boxW / 2; + + const formatBytes = (bytes: number): { num: string, unit: string } => { + const units = ['B', 'kB', 'MB', 'GB']; + const base = 1000; + let value = bytes; + let unitIndex = 0; + while (value >= base && unitIndex < units.length - 1) { + value /= base; + unitIndex++; + } + return { num: formatNumber(value, unitIndex === 0 ? '1.0-0' : '1.2-2'), unit: units[unitIndex] }; + }; + + const size = formatBytes(tx.size); + const weight = formatWeightUnit(tx.weight, 2); + + ctx.font = 'bold 32px Roboto'; + const sizeNumWidth = ctx.measureText(size.num).width; + ctx.font = '24px Roboto'; + const sizeUnitWidth = ctx.measureText(` ${size.unit}`).width; + + ctx.font = 'bold 32px Roboto'; + const weightNumWidth = ctx.measureText(weight.num).width; + ctx.font = '24px Roboto'; + const weightUnitWidth = ctx.measureText(` ${weight.unit}`).width; + + const spacing = ctx.measureText(' ').width; + const totalWidth = sizeNumWidth + sizeUnitWidth + spacing + weightNumWidth + weightUnitWidth; + + let x = statsX - totalWidth / 2; + + ctx.font = 'bold 32px Roboto'; + ctx.fillStyle = themes.default.fg; + ctx.textAlign = 'left'; + ctx.fillText(size.num, x, statsY); + x += sizeNumWidth; + + ctx.font = '24px Roboto'; + ctx.fillStyle = themes.default.symbol; + ctx.fillText(` ${size.unit}`, x, statsY + 2); + x += sizeUnitWidth + spacing; + + ctx.font = 'bold 32px Roboto'; + ctx.fillStyle = themes.default.fg; + ctx.fillText(weight.num, x, statsY); + x += weightNumWidth; + + ctx.font = '24px Roboto'; + ctx.fillStyle = themes.default.symbol; + ctx.fillText(` ${weight.unit}`, x, statsY + 2); + + if (!isCoinbase) { + const feeRate = tx.fee / (tx.weight / 4); + const feeRateNum = formatNumber(feeRate, '1.2-2'); + + ctx.font = 'bold 32px Roboto'; + const feeRateNumWidth = ctx.measureText(feeRateNum).width; + ctx.font = '24px Roboto'; + const feeRateUnitWidth = ctx.measureText(' sat/vB').width; + const feeRateTotalWidth = feeRateNumWidth + feeRateUnitWidth; + + let feeX = statsX - feeRateTotalWidth / 2; + + ctx.font = 'bold 32px Roboto'; + ctx.fillStyle = themes.default.fg; + ctx.textAlign = 'left'; + ctx.fillText(feeRateNum, feeX, statsY + 40); + feeX += feeRateNumWidth; + + // Draw fee rate unit + ctx.font = '24px Roboto'; + ctx.fillStyle = themes.default.symbol; + ctx.fillText(' sat/vB', feeX, statsY + 40 + 2); + } + } + } + }, + + txViz: (txid: string, position: Position): Component => { + return { + type: 'tx-viz', + data: [ + dataRequirements.tx(txid), + ], + render: async (ctx: CanvasRenderingContext2D, data: any): Promise => { + const bounds = { ...position, w: position.w ?? 1200, h: position.h ?? 431 } as Rect; + const tx = data[`tx_${txid}`]; + + const boxPadding = 10; + ctx.save(); + ctx.beginPath(); + ctx.rect( + bounds.x - boxPadding, + bounds.y - boxPadding, + bounds.w + (boxPadding * 2), + bounds.h + (boxPadding * 2) + ); + ctx.clip(); + + renderTxBowtie(ctx, tx, bounds, 'default'); + + ctx.restore(); + } + } + }, + table: (position: Position, propsCallback?: (data: any, params: any, parentProps: any) => { tableRows: TableRow[] }): Component => ({ type: 'table', data: [], @@ -389,6 +576,15 @@ export const views: Record Component> = { components.blockContent(hash, { x: 0, y: 80, w: 1200, h: 520 }) ] }), + tx: (txid: string): Component => ({ + type: 'tx', + data: [], + children: [ + components.background({ x: 0, y: 0 }), + components.header('Transaction', { x: 0, y: 0 }), + components.txContent(txid, { x: 0, y: 80, w: 1200, h: 520 }) + ] + }), address: (address: string): Component => ({ type: 'address', data: [], diff --git a/unfurler/src/canvas/tx-bowtie/tx-bowtie.ts b/unfurler/src/canvas/tx-bowtie/tx-bowtie.ts new file mode 100644 index 000000000..44a666a86 --- /dev/null +++ b/unfurler/src/canvas/tx-bowtie/tx-bowtie.ts @@ -0,0 +1,360 @@ +import { CanvasRenderingContext2D } from 'canvas'; +import { Rect } from '../components'; +import { IEsploraApi } from '../../api/esplora-api.interface'; + +interface Xput { + type: 'input' | 'output' | 'fee'; + value?: number; + index?: number; + rest?: number; +} + +interface LineParams { + weight: number; + thickness: number; + offset: number; + innerY: number; + outerY: number; +} + +const gradientColors = { + default: ['#9339f4', '#105fb0'], + liquid: ['#09a197', '#0f62af'], + liquidtestnet: ['#d2d2d2', '#979797'], + testnet: ['#4edf77', '#10a0af'], + testnet4: ['#4edf77', '#10a0af'], + signet: ['#d24fc8', '#a84fd2'], +}; + +export function renderTxBowtie( + ctx: CanvasRenderingContext2D, + tx: IEsploraApi.Transaction, + bounds: Rect, + theme: string +): void { + const width = bounds.w; + const height = bounds.h; + const lineLimit = 250; + const maxCombinedWeight = 100; + const minWeight = 2; + const maxStrands = 24; + + const midWidth = Math.min(10, Math.ceil(width / 100)); + const txWidth = width - 20; + const combinedWeight = Math.min(maxCombinedWeight, Math.floor((txWidth - (2 * midWidth)) / 6)); + const zeroValueWidth = Math.max(20, Math.min((txWidth / 2) - midWidth - 110, 60)); + const zeroValueThickness = 20; + + const totalValue = calcTotalValue(tx); + + let voutWithFee: Xput[] = tx.vout.map((v, i) => ({ + type: v.scriptpubkey_type === 'fee' ? 'fee' : 'output', + value: v.value, + index: i, + })); + + if (tx.fee) { + voutWithFee.unshift({ type: 'fee', value: tx.fee }); + } + + let truncatedInputs: Xput[] = tx.vin.map((v, i) => ({ + type: 'input', + value: v?.is_coinbase && !totalValue ? 0 : v?.prevout?.value, + index: i, + })); + + if (truncatedInputs.length > lineLimit) { + const valueOfRest = truncatedInputs.slice(lineLimit).reduce((r, v) => r + (v.value || 0), 0) || 0; + truncatedInputs = truncatedInputs.slice(0, lineLimit); + truncatedInputs.push({ type: 'input', value: valueOfRest, rest: tx.vin.length - lineLimit }); + } + if (voutWithFee.length > lineLimit) { + const valueOfRest = voutWithFee.slice(lineLimit).reduce((r, v) => r + (v.value || 0), 0) || 0; + voutWithFee = voutWithFee.slice(0, lineLimit); + voutWithFee.push({ type: 'output', value: valueOfRest, rest: voutWithFee.length - lineLimit }); + } + + const inputLines = initLines(truncatedInputs, totalValue, combinedWeight, minWeight, maxStrands, zeroValueThickness, height); + const outputLines = initLines(voutWithFee, totalValue, combinedWeight, minWeight, maxStrands, zeroValueThickness, height); + + ctx.save(); + ctx.translate(bounds.x, bounds.y); + + const outerColor = gradientColors[theme]?.[0] || gradientColors.default[0]; + const innerColor = gradientColors[theme]?.[1] || gradientColors.default[1]; + + inputLines.forEach((line) => { + if (line.zeroValue) { + drawZeroValuePath(ctx, 'in', line.outerY, zeroValueWidth, zeroValueThickness, width, outerColor); + } else { + const connectorWidth = 10; + const markerWidth = Math.max(line.thickness / 2, 8); + const lineStart = connectorWidth + markerWidth; + const lineEnd = width / 2 - midWidth; + const inputGradient = ctx.createLinearGradient(lineStart, 0, lineEnd, 0); + inputGradient.addColorStop(0, outerColor); + inputGradient.addColorStop(1, innerColor); + + drawPath(ctx, 'in', line.outerY || 0, line.innerY || 0, line.thickness, line.offset || 0, line.pad || 0, width, midWidth, inputGradient); + drawMarker(ctx, 'in', line.outerY || 0, line.thickness, width, outerColor); + } + }); + + outputLines.forEach((line, index) => { + if (line.zeroValue) { + drawZeroValuePath(ctx, 'out', line.outerY, zeroValueWidth, zeroValueThickness, width, outerColor); + } else { + const connectorWidth = 10; + const markerWidth = Math.max(line.thickness / 2, 8); + const lineStart = width / 2 + midWidth; + const lineEnd = width - connectorWidth - markerWidth; + const outputGradient = ctx.createLinearGradient(lineStart, 0, lineEnd, 0); + + const isFee = voutWithFee[index]?.type === 'fee'; + outputGradient.addColorStop(0, innerColor); + outputGradient.addColorStop(1, isFee ? '#181b2d' : outerColor); + + drawPath(ctx, 'out', line.outerY || 0, line.innerY || 0, line.thickness, line.offset || 0, line.pad || 0, width, midWidth, outputGradient); + if (!isFee) { + drawMarker(ctx, 'out', line.outerY || 0, line.thickness, width, outerColor); + } + } + }); + + ctx.strokeStyle = innerColor; + ctx.lineWidth = combinedWeight + 0.5; + ctx.lineCap = 'butt'; + ctx.beginPath(); + ctx.moveTo((width / 2) - midWidth, (height / 2) + 0.25); + ctx.lineTo((width / 2) + midWidth, (height / 2) + 0.25); + ctx.stroke(); + + ctx.restore(); +} + +function calcTotalValue(tx: IEsploraApi.Transaction): number { + const totalOutput = tx.vout.reduce((acc, v) => (v.value || 0) + acc, 0); + return tx.fee ? totalOutput + tx.fee : totalOutput; +} + +function initLines( + xputs: Xput[], + total: number, + combinedWeight: number, + minWeight: number, + maxVisibleStrands: number, + zeroValueThickness: number, + height: number +): Array { + if (!total) { + const weights = xputs.map(() => combinedWeight / xputs.length); + return linesFromWeights(xputs, weights, combinedWeight, minWeight, maxVisibleStrands, zeroValueThickness, height); + } else { + let unknownCount = 0; + let unknownTotal = total; + xputs.forEach(put => { + if (put.value == null) { + unknownCount++; + } else { + unknownTotal -= put.value; + } + }); + const unknownShare = unknownTotal / unknownCount; + const weights = xputs.map((put) => combinedWeight * (put.value == null ? unknownShare : put.value) / total); + return linesFromWeights(xputs, weights, combinedWeight, minWeight, maxVisibleStrands, zeroValueThickness, height); + } +} + +function linesFromWeights( + xputs: Xput[], + weights: number[], + combinedWeight: number, + minWeight: number, + maxVisibleStrands: number, + zeroValueThickness: number, + height: number +): Array { + const lineParams: Array = weights.map((w, i) => ({ + weight: w, + thickness: xputs[i].value === 0 ? zeroValueThickness : Math.min(combinedWeight + 0.5, Math.max(minWeight - 1, w) + 1), + offset: 0, + innerY: 0, + outerY: 0, + zeroValue: xputs[i].value === 0, + })); + + const visibleStrands = Math.min(maxVisibleStrands, xputs.length); + const visibleWeight = lineParams.slice(0, visibleStrands).reduce((acc, v) => v.thickness + acc, 0); + const gaps = visibleStrands - 1; + + const innerTop = (height / 2) - (combinedWeight / 2); + const innerBottom = innerTop + combinedWeight + 0.5; + let lastOuter = 0; + let lastInner = innerTop; + const spacing = Math.max(4, (height - visibleWeight) / gaps); + + let offset = 0; + let minOffset = 0; + let maxOffset = 0; + let lastWeight = 0; + let pad = 0; + + lineParams.forEach((line, i) => { + if (xputs[i].value === 0) { + line.outerY = lastOuter + (zeroValueThickness / 2); + if (xputs.length === 1) { + line.outerY = (height / 2); + } + lastOuter += zeroValueThickness + spacing; + return; + } + + line.outerY = lastOuter + (line.thickness / 2); + line.innerY = Math.min(innerBottom - (line.thickness / 2), Math.max(innerTop + (line.thickness / 2), lastInner + (line.weight / 2))); + + if (xputs.length === 1) { + line.outerY = (height / 2); + } + + lastOuter += line.thickness + spacing; + lastInner += line.weight; + + if (!xputs[i].rest) { + const w = (maxVisibleStrands - Math.max(lastWeight, line.weight)) / 2; + const y1 = line.outerY; + const y2 = line.innerY; + const t = (lastWeight + line.weight) / 2; + + const dx = 0.75 * w; + const dy = 1.5 * (y2 - y1); + const a = Math.atan2(dy, dx); + + if (Math.sin(a) !== 0) { + offset += Math.max(Math.min(t * (1 - Math.cos(a)) / Math.sin(a), t), -t); + } + + line.offset = offset; + minOffset = Math.min(minOffset, offset); + maxOffset = Math.max(maxOffset, offset); + pad = Math.max(pad, line.thickness / 2); + lastWeight = line.weight; + } + }); + + lineParams.forEach((line) => { + line.offset -= minOffset; + line.pad = pad + (maxOffset - minOffset); + }); + + return lineParams; +} + +function drawPath( + ctx: CanvasRenderingContext2D, + side: 'in' | 'out', + outer: number, + inner: number, + weight: number, + offset: number, + pad: number, + width: number, + midWidth: number, + gradient: CanvasGradient +): void { + const connectorWidth = 10; + const start = (weight * 0.5) + connectorWidth; + const curveStart = Math.max(start + 5, pad + connectorWidth - offset); + const end = width / 2 - (midWidth * 0.9) + 1; + const curveEnd = end - offset - 10; + const midpoint = (curveStart + curveEnd) / 2; + + let adjustedOuter = outer; + if (Math.round(outer) === Math.round(inner)) { + adjustedOuter -= 1; + } + + ctx.strokeStyle = gradient; + ctx.lineWidth = weight; + ctx.lineCap = 'butt'; + ctx.beginPath(); + + if (side === 'in') { + ctx.moveTo(start, adjustedOuter); + ctx.lineTo(curveStart, adjustedOuter); + ctx.bezierCurveTo(midpoint, adjustedOuter, midpoint, inner, curveEnd, inner); + ctx.lineTo(end, inner); + } else { + ctx.moveTo(width - start, adjustedOuter); + ctx.lineTo(width - curveStart, adjustedOuter); + ctx.bezierCurveTo(width - midpoint, adjustedOuter, width - midpoint, inner, width - curveEnd, inner); + ctx.lineTo(width - end, inner); + } + + ctx.stroke(); +} + +function drawZeroValuePath( + ctx: CanvasRenderingContext2D, + side: 'in' | 'out', + y: number, + zeroValueWidth: number, + zeroValueThickness: number, + width: number, + color: string +): void { + const offset = zeroValueThickness / 2; + const start = 15; + + ctx.strokeStyle = color; + ctx.lineWidth = zeroValueThickness; + ctx.lineCap = 'round'; + ctx.beginPath(); + + if (side === 'in') { + ctx.moveTo(start + offset, y); + ctx.lineTo(start + zeroValueWidth + offset, y); + } else { + ctx.moveTo(width - start - offset, y); + ctx.lineTo(width - start - zeroValueWidth - offset, y); + } + + ctx.stroke(); +} + +function drawMarker( + ctx: CanvasRenderingContext2D, + side: 'in' | 'out', + y: number, + thickness: number, + width: number, + color: string +): void { + const halfThickness = thickness / 2; + const markerWidth = Math.max(halfThickness, 8); + const connectorWidth = 10; + const overlap = 1; + + ctx.fillStyle = color; + + if (side === 'in') { + const x = connectorWidth + overlap; + ctx.beginPath(); + ctx.moveTo(x, y - halfThickness); + ctx.lineTo(x + markerWidth, y - halfThickness); + ctx.lineTo(x + markerWidth, y + halfThickness); + ctx.lineTo(x, y + halfThickness); + ctx.lineTo(x, y + halfThickness); + ctx.lineTo(x + markerWidth, y); + ctx.lineTo(x, y - halfThickness); + ctx.closePath(); + ctx.fill('evenodd'); + } else { + const x = width - connectorWidth - overlap; + ctx.beginPath(); + ctx.moveTo(x - markerWidth, y - halfThickness); + ctx.lineTo(x, y); + ctx.lineTo(x - markerWidth, y + halfThickness); + ctx.closePath(); + ctx.fill(); + } +} diff --git a/unfurler/src/routes.ts b/unfurler/src/routes.ts index 1226ccfa2..51e77a1b0 100644 --- a/unfurler/src/routes.ts +++ b/unfurler/src/routes.ts @@ -161,6 +161,7 @@ const routes = { } } }, + canvasView: 'tx', routes: { push: { title: "Push Transaction",