import { formatUnits } from 'viem' import type * as Webhooks from '../Webhooks.js' import * as Viem from '../Viem.js' import * as WebhookDestination from '../WebhookDestination.js' /** * Builds a `slack` destination. Its `deliver` formats the envelope into a * per-event Block Kit message ({@link formatBlocks}) and POSTs it to the Slack * incoming-webhook URL unsigned (the URL is the shared secret; host-locked to * `hooks.slack.com`). Deliver with `.deliver({ envelope })`. */ export function slack(url: string): WebhookDestination.Instance { assertSlackUrl(url) return { deliver(options) { return WebhookDestination.send({ body: JSON.stringify(formatBlocks(options.envelope)), errorPrefix: 'slack ', fetch: options.fetch, now: options.now, timeoutMs: options.timeoutMs, url, }) }, type: 'slack', url, } } /** A Slack `section.fields` / `context.elements` entry. */ type Field = { text: string; type: 'mrkdwn' } /** A Slack Block Kit block (loosely typed; only the shapes we emit). */ type Block = | { elements: Field[]; type: 'context' } | { fields: Field[]; type: 'section' } | { text: { emoji: true; text: string; type: 'plain_text' }; type: 'header' } | { text: Field; type: 'section' } /** Per-event emoji + friendly header name; also the notification fallback lead. */ const friendlyName: Record = { 'block:created': ':link: Block created', 'funding:deposit.updated': ':inbox_tray: Funding deposit updated', 'funding:transfer.updated': ':arrows_counterclockwise: Funding transfer updated', 'log:emitted': ':label: Log emitted', ping: ':bell: Webhook test ping', 'token:transfer': ':money_with_wings: Token transfer', 'transaction:included': ':package: Transaction included', } /** * Chain id → block-explorer base URL, derived from {@link Viem.chains}. Used to * render addresses/hashes/tokens/blocks as explorer links; an unknown chain id * (no base) degrades to plain truncated text. */ const explorerBase: Record = Object.fromEntries( Viem.chains.flatMap((chain) => { const base = chain.blockExplorers?.default.url return base ? [[chain.id, base] as const] : [] }), ) /** * Formats an envelope into a Slack Block Kit message (`{ blocks, text }`) using a * per-event layout. Every message shares the cross-cutting rules: a header (the * subscription's `context.title` when set, else the event's friendly name), an * optional description section, an event-specific lead + fields grid, and a * trailing context line that always preserves the raw event type/id/chain/time. * Pure and synchronous — all inputs come from the envelope or static chain data. */ export function formatBlocks(envelope: Webhooks.Envelope): { blocks: Block[]; text: string } { const base = explorerBase[envelope.chainId] const data = typeof envelope.data === 'object' && envelope.data !== null ? (envelope.data as Record) : {} const layout = renderEvent(envelope.type, data, base) const blocks: Block[] = [ { text: { emoji: true, text: clamp(envelope.context?.title ?? layout.header, 150), type: 'plain_text', }, type: 'header', }, ] if (envelope.context?.description !== undefined) blocks.push({ text: { text: escape(envelope.context.description), type: 'mrkdwn' }, type: 'section', }) if (layout.lead !== undefined) blocks.push({ text: { text: layout.lead, type: 'mrkdwn' }, type: 'section' }) if (layout.fields.length > 0) blocks.push({ fields: layout.fields.slice(0, 10), type: 'section' }) const metadata = envelope.context?.metadata if (metadata && Object.keys(metadata).length > 0) blocks.push({ fields: Object.entries(metadata) .slice(0, 10) .map(([key, value]) => ({ text: `*${escape(key)}*\n${escape(value)}`, type: 'mrkdwn' as const, })), type: 'section', }) blocks.push({ // Keep the raw event type/id/chain/time here so event identity survives a // custom `context.title` header. elements: [ { text: `\`${escape(envelope.id)}\` · \`${escape(envelope.type)}\` · chain ${envelope.chainId}${ date(envelope.createdAt) ? ` · ${date(envelope.createdAt)}` : '' }`, type: 'mrkdwn', }, ], type: 'context', }) // The notification/push preview: a custom title when set, else the event's // one-line summary (with the chain appended). Never contains mrkdwn link / // `` syntax. return { blocks, text: envelope.context?.title ?? `${layout.text} · chain ${envelope.chainId}`, } } /** The event-specific parts of a message, before the shared header/context wrap. */ type Layout = { /** Field grid entries (capped to 10 by the caller). */ fields: Field[] /** Friendly header name (emoji + label) when there's no `context.title`. */ header: string /** Optional summary section under the header. */ lead?: string | undefined /** One-line notification fallback (plain text; no links/``). */ text: string } /** Dispatches to the per-event layout builder. */ function renderEvent( type: Webhooks.Envelope['type'], data: Record, base: string | undefined, ): Layout { if (type === 'token:transfer') return transferLayout(data, base) if (type === 'transaction:included') return transactionLayout(data, base) if (type === 'log:emitted') return logLayout(data, base) if (type === 'block:created') return blockLayout(data, base) if (type === 'funding:deposit.updated') return fundingDepositLayout(data) if (type === 'funding:transfer.updated') return fundingTransferLayout(data) return pingLayout() } function fundingDepositLayout(data: Record): Layout { const fields: Field[] = [] field(fields, 'Deposit', escape(str(data['id']) ?? 'unknown')) field(fields, 'Status', escape(str(data['status']) ?? 'unknown')) const recipient = str(data['recipient']) if (recipient !== undefined) field(fields, 'Recipient', escape(recipient)) return { fields, header: friendlyName['funding:deposit.updated'], text: `Funding deposit updated · ${str(data['status']) ?? 'unknown'}`, } } function fundingTransferLayout(data: Record): Layout { const fields: Field[] = [] field(fields, 'Transfer', escape(str(data['id']) ?? 'unknown')) field(fields, 'Status', escape(str(data['status']) ?? 'unknown')) const recipient = str(data['recipient']) if (recipient !== undefined) field(fields, 'Recipient', escape(recipient)) return { fields, header: friendlyName['funding:transfer.updated'], text: `Funding transfer updated · ${str(data['status']) ?? 'unknown'}`, } } function transferLayout(data: Record, base: string | undefined): Layout { const amount = str(data['amount']) const token = typeof data['token'] === 'object' && data['token'] !== null ? (data['token'] as { decimals?: number | undefined; symbol?: string | undefined }) : undefined const human = humanAmount(amount, token) const sender = str(data['sender']) const recipient = str(data['recipient']) const tokenAddress = str(data['address']) const fields: Field[] = [] if (tokenAddress !== undefined) field(fields, 'Token', tokenLink(base, tokenAddress, token?.symbol)) if (amount !== undefined) field(fields, 'Amount', escape(human ?? amount)) if (sender !== undefined) field(fields, 'Sender', addressLink(base, sender)) if (recipient !== undefined) field(fields, 'Recipient', addressLink(base, recipient)) field(fields, 'Block', blockLink(base, data['blockNumber'])) if (str(data['transactionHash']) !== undefined) field(fields, 'Tx', txLink(base, str(data['transactionHash'])!)) field(fields, 'Timestamp', date(str(data['timestamp']))) const lead = sender !== undefined && recipient !== undefined ? `*${escape(human ?? amount ?? 'Transfer')}* from ${addressLink(base, sender)} → ${addressLink(base, recipient)}` : undefined return { fields, header: friendlyName['token:transfer'], ...(lead === undefined ? {} : { lead }), text: `Token transfer · ${human ?? amount ?? 'transfer'}`, } } function transactionLayout(data: Record, base: string | undefined): Layout { const meta = typeof data['meta'] === 'object' && data['meta'] !== null ? data['meta'] : undefined const receipt = meta && typeof (meta as Record)['receipt'] === 'object' ? ((meta as Record)['receipt'] as Record) : undefined const status = receipt ? str(receipt['status']) : undefined const reverted = status === 'reverted' const type = str(data['type']) const sender = str(data['sender']) const contractCreation = data['recipient'] === null const recipient = str(data['recipient']) const recipientText = contractCreation ? 'contract creation' : recipient && addressLink(base, recipient) const calls = Array.isArray(data['calls']) ? (data['calls'] as unknown[]) : undefined const fields: Field[] = [] if (status !== undefined) field(fields, 'Status', escape(status)) if (type !== undefined) field(fields, 'Type', escape(type)) if (sender !== undefined) field(fields, 'Sender', addressLink(base, sender)) if (recipientText) field(fields, 'Recipient', recipientText) if (str(data['value']) !== undefined) field(fields, 'Value', escape(str(data['value'])!)) if (str(data['hash']) !== undefined) field(fields, 'Tx', txLink(base, str(data['hash'])!)) field( fields, 'Block', data['blockNumber'] === null ? 'pending' : blockLink(base, data['blockNumber']), ) const gas = scalar(data['gas']) const gasPrice = scalar(data['gasPrice']) if (gas !== undefined) field( fields, 'Gas', gasPrice === undefined ? escape(gas) : `${escape(gas)} @ ${escape(gasPrice)}`, ) if (calls && calls.length > 0) field(fields, 'Calls', String(calls.length)) field(fields, 'Timestamp', date(str(data['timestamp']))) const accent = reverted ? ':x: *reverted* · ' : status === 'success' ? ':white_check_mark: ' : '' const flow = sender !== undefined && recipientText ? `${addressLink(base, sender)} → ${recipientText}` : undefined const lead = flow === undefined && type === undefined && accent === '' ? undefined : `${accent}${type ? `${escape(type)} ` : ''}${flow ?? ''}`.trim() return { fields, header: reverted ? ':x: Transaction reverted' : friendlyName['transaction:included'], ...(lead ? { lead } : {}), text: `Transaction included${status ? ` · ${status}` : ''}`, } } function logLayout(data: Record, base: string | undefined): Layout { const event = typeof data['event'] === 'object' && data['event'] !== null ? (data['event'] as Record) : undefined const name = event ? str(event['name']) : undefined const decoded = name !== undefined const address = str(data['address']) const args = typeof data['args'] === 'object' && data['args'] !== null ? (data['args'] as Record) : undefined // Reserve the always-present coordinates first, then fill remaining slots (up // to 10 total) with decoded args, with a trailing "…and N more" if truncated. const fields: Field[] = [] if (address !== undefined) field(fields, 'Contract', addressLink(base, address)) if (!decoded && event && str(event['topic0']) !== undefined) field(fields, 'Topic0', escape(truncateHash(str(event['topic0'])!))) field(fields, 'Block', blockLink(base, data['blockNumber'])) if (str(data['transactionHash']) !== undefined) field(fields, 'Tx', txLink(base, str(data['transactionHash'])!)) if (!decoded) field(fields, 'Log index', scalar(data['logIndex'])) field(fields, 'Timestamp', date(str(data['timestamp']))) if (decoded && args) { const entries = Object.entries(args) const room = Math.max(0, 10 - fields.length) for (const [key, value] of entries.slice(0, room)) field(fields, key, argValue(value, base)) if (entries.length > room) field(fields, '', `…and ${entries.length - room} more`) } const lead = decoded ? `*${escape(name)}* on ${address ? addressLink(base, address) : 'contract'}` : address !== undefined ? `Log on ${addressLink(base, address)}${ event && str(event['topic0']) ? ` · topic0 ${escape(truncateHash(str(event['topic0'])!))}` : '' }` : undefined return { fields, header: decoded ? clamp(`:label: Log: ${name}`, 150) : friendlyName['log:emitted'], ...(lead === undefined ? {} : { lead }), text: decoded ? `Log: ${name}${address ? ` · ${truncateAddress(address)}` : ''}` : `Log emitted${address ? ` · ${truncateAddress(address)}` : ''}`, } } function blockLayout(data: Record, base: string | undefined): Layout { const number = scalar(data['number']) const txCount = scalar(data['transactionCount']) const gasUsedNum = data['gasUsed'] const gasLimitNum = data['gasLimit'] const gasUsed = scalar(gasUsedNum) const gasLimit = scalar(gasLimitNum) const miner = str(data['miner']) const proposer = str(data['proposer']) const hash = str(data['hash']) const fields: Field[] = [] field(fields, 'Number', blockLink(base, data['number'])) field(fields, 'Txns', txCount) if (gasUsed !== undefined && gasLimit !== undefined) field(fields, 'Gas', `${escape(gasUsed)} / ${escape(gasLimit)}`) if (miner !== undefined) field(fields, 'Producer', addressLink(base, miner)) if (proposer !== undefined) field(fields, 'Proposer', addressLink(base, proposer)) if (hash !== undefined) field(fields, 'Hash', blockHashLink(base, hash)) field(fields, 'Timestamp', date(str(data['timestamp']))) const percent = typeof gasUsedNum === 'number' && typeof gasLimitNum === 'number' && gasLimitNum > 0 ? ` (${((gasUsedNum / gasLimitNum) * 100).toFixed(1)}%)` : '' const lead = txCount !== undefined ? `*${escape(txCount)}* transactions${ gasUsed !== undefined && gasLimit !== undefined ? ` · gas ${escape(gasUsed)} / ${escape(gasLimit)}${percent}` : '' }` : undefined return { fields, header: number === undefined ? friendlyName['block:created'] : clamp(`:link: Block created #${number}`, 150), ...(lead === undefined ? {} : { lead }), text: `Block created${number === undefined ? '' : ` #${number}`}${ txCount === undefined ? '' : ` · ${txCount} txns` }`, } } function pingLayout(): Layout { return { fields: [], header: friendlyName.ping, lead: 'Your Slack destination is wired up correctly.', text: 'Webhook test ping', } } /** Appends a `*Label*\nvalue` field when the value is present. */ function field(fields: Field[], label: string, value: string | undefined): void { if (value === undefined) return fields.push({ text: label ? `*${label}*\n${value}` : value, type: 'mrkdwn' }) } /** Renders a human token amount (`10.00 USDC`) from base units + metadata. */ function humanAmount( amount: string | undefined, token: { decimals?: number | undefined; symbol?: string | undefined } | undefined, ): string | undefined { if (amount === undefined) return undefined if (token?.decimals === undefined) return amount try { const formatted = formatUnits(BigInt(amount), token.decimals) return token.symbol ? `${formatted} ${token.symbol}` : formatted } catch { return amount } } /** Renders a decoded log arg: address args link, everything else escaped text. */ function argValue(value: unknown, base: string | undefined): string { if (typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)) return addressLink(base, value) return escape(scalar(value) ?? JSON.stringify(value)) } /** `` when an explorer is known, else plain truncated text. */ function addressLink(base: string | undefined, address: string): string { return link(base, 'address', address, truncateAddress(address)) } /** `` for a transaction hash. */ function txLink(base: string | undefined, hash: string): string { return link(base, 'tx', hash, truncateHash(hash)) } /** `` (or truncated address) for a token contract. */ function tokenLink(base: string | undefined, address: string, symbol: string | undefined): string { return link(base, 'token', address, symbol ?? truncateAddress(address)) } /** `` for a block number (accepts number/string). */ function blockLink(base: string | undefined, number: unknown): string | undefined { const id = scalar(number) if (id === undefined) return undefined return link(base, 'block', id, id) } /** `` linking a block by its hash. */ function blockHashLink(base: string | undefined, hash: string): string { return link(base, 'block', hash, truncateHash(hash)) } /** Builds a Slack mrkdwn link, falling back to escaped plain text with no base. */ function link(base: string | undefined, path: string, id: string, label: string): string { if (!base) return escape(label) return `<${base}/${path}/${encodeURIComponent(id)}|${escape(label)}>` } /** Localized time via Slack's `` token; `undefined` for a bad timestamp. */ function date(iso: string | undefined): string | undefined { if (iso === undefined) return undefined const ms = Date.parse(iso) if (Number.isNaN(ms)) return undefined return `` } /** Middle-truncates an address to `0x9e39…1754` (6+4). */ function truncateAddress(value: string): string { return value.length > 12 ? `${value.slice(0, 6)}…${value.slice(-4)}` : value } /** Middle-truncates a hash to `0x3d24a706…24bbf9d3` (10+8). */ function truncateHash(value: string): string { return value.length > 20 ? `${value.slice(0, 10)}…${value.slice(-8)}` : value } /** Escapes the three mrkdwn-significant characters in interpolated values. */ function escape(value: string): string { return value.replace(/&/g, '&').replace(//g, '>') } /** Clamps a header string to Slack's length cap. */ function clamp(value: string, max: number): string { return value.length > max ? `${value.slice(0, max - 1)}…` : value } /** Reads a string value, or `undefined` when the key isn't a string. */ function str(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } /** Reads a scalar (number/bigint/boolean/string) value as a plain string. */ function scalar(value: unknown): string | undefined { if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value) return typeof value === 'string' ? value : undefined } /** Host that Slack incoming-webhook URLs must use. */ const slackWebhookHost = 'hooks.slack.com' /** * Validates a `slack` destination's incoming-webhook URL: must be `https` and * hosted at {@link slackWebhookHost}. The host lock keeps `slack` destinations * from becoming a generic "POST an unsigned message anywhere" primitive — a * `slack` delivery carries no signature, so it must only ever reach Slack. */ export function assertSlackUrl(input: string): URL { let url: URL try { url = new URL(input) } catch { throw new WebhookDestination.InvalidUrlError('malformed', input) } if (url.protocol !== 'https:') throw new WebhookDestination.InvalidUrlError('protocol', input) if (url.username || url.password) throw new WebhookDestination.InvalidUrlError('credentials', input) if (url.hostname !== slackWebhookHost) throw new WebhookDestination.InvalidUrlError('slack_host', input) return url } /** * Masks the secret path of a Slack incoming-webhook URL for logging, keeping the * host so the destination is still recognizable: `https://hooks.slack.com/…`. * Falls back to the bare origin if the URL can't be parsed. */ export function redactSlackUrl(url: string): string { try { return `${new URL(url).origin}/…` } catch { return 'https://hooks.slack.com/…' } }