import { type Context, Hono } from 'hono' import { type AbiEvent, decodeEventLog, encodeEventTopics, parseAbiItem, type RpcLog } from 'viem' import * as z from 'zod/mini' import type * as App from '../../../App.js' import * as Scope from '../../../Scope.js' import * as Auth from '../../../internal/Auth.js' import * as Cache from '../../../internal/Cache.js' import * as Db from '../../../db/Db.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Store from '../../../internal/Store.js' import * as Ttl from '../../../internal/Ttl.js' import * as Value from '../../../internal/Value.js' import type * as Viem from '../../../internal/Viem.js' import * as WebhookDestination from '../../../internal/WebhookDestination.js' import * as WebhookTransfer from '../../../internal/WebhookTransfer.js' import * as Webhooks from '../../../internal/Webhooks.js' import * as FundingDeposit from '../../../internal/funding/Deposit.js' import * as FundingTransfer from '../../../internal/funding/Transfer.js' import * as Transactions from './transactions.js' import * as Transfers from './transfers.js' /** Default cap on live subscriptions per owner, overridable via `webhook.maxPerOwner`. */ const defaultMaxPerOwner = 100 /** * Lifetime of an MPP-payer-owned subscription. A payer pays once, so the * subscription is TTL-bounded (renewable by another paid create) to bound the * paid-once / deliver-forever mismatch. API-key-owned subscriptions have no TTL. */ const mppTtlMs = 30 * 24 * 60 * 60 * 1_000 const redactedBetterstackToken = '[redacted]' const redactedSlackUrl = 'https://hooks.slack.com/…' /** * The subscribable webhook event types, in alphabetical order. The source of * truth for the event-type *set*: the {@link EventType} union and the route zod * enum ({@link schema.EventType}) both derive from this. */ export const eventTypes = [ 'block:created', 'funding:deposit.updated', 'funding:transfer.updated', 'log:emitted', 'token:transfer', 'transaction:included', ] as const /** Event types a subscription can listen to. Derived from {@link eventTypes}. */ export type EventType = (typeof eventTypes)[number] /** Funding resource events produced by the API instead of chain scans. */ export const fundingEventTypes = [ 'funding:deposit.updated', 'funding:transfer.updated', ] as const satisfies readonly EventType[] /** Event type carrying an owner-scoped funding resource. */ export type FundingEventType = (typeof fundingEventTypes)[number] /** Returns whether an event type carries an owner-scoped funding resource. */ export function isFundingEventType(eventType: EventType): eventType is FundingEventType { return (fundingEventTypes as readonly EventType[]).includes(eventType) } /** Subscribable event types surfaced by `GET /webhooks/event-types`. */ const eventTypeDescriptions = [ { description: 'A new block was added to the chain. Filter by producer, gas usage, or block number, or omit filters for a per-block heartbeat.', type: 'block:created', }, { description: 'A funding deposit materially changed after verified reconciliation, filterable by deposit address, recipient, or status.', type: 'funding:deposit.updated', }, { description: 'A funding transfer materially changed, filterable by transfer ID or lifecycle status.', type: 'funding:transfer.updated', }, { description: 'Any contract event log, filtered by emitting address, event signature/topics, or decoded arguments.', type: 'log:emitted', }, { description: 'TIP-20 token transfer event, such as a stablecoin payment sent or received.', type: 'token:transfer', }, { description: 'A transaction included in a block, filterable on transaction fields such as sender, recipient, value, calldata, inner account-abstraction calls, fees, and nonce.', type: 'transaction:included', }, ] as const satisfies readonly { description: string; type: EventType }[] /** Zod schemas owned by the webhooks resource. */ export namespace schema { /** Subscribable event type. */ export const EventType = z.enum(eventTypes).check(z.describe('Event type you can subscribe to.')) /** Subscription lifecycle status. */ export const Status = z .enum(['active', 'disabled', 'paused']) .check( z.describe('Current state of the webhook subscription.'), z.meta({ examples: ['active'] }), ) const Filters = z.record(z.string(), z.unknown()) const chainIdResponse = z .number() .check( z.int(), z.positive(), z.describe('Tempo chain ID for this subscription.'), z.meta({ examples: [4217] }), ) const Url = z .url() .check( z.describe('Your HTTPS endpoint where Tempo sends signed event POSTs.'), z.meta({ examples: ['https://example.com/webhooks'] }), ) const SlackUrl = z.url().check( z.refine((url) => url !== redactedSlackUrl, { error: 'A Slack webhook URL is required.' }), z.describe('Your Slack incoming-webhook URL (`https://hooks.slack.com/services/…`).'), z.meta({ examples: ['https://hooks.slack.com/services/T000/B000/XXXXXXXX'] }), ) const BetterstackUrl = z .url() .check( z.describe( 'Your Better Stack source ingest host URL (`https://.betterstackdata.com`).', ), z.meta({ examples: ['https://s1234567.eu-nbg-2.betterstackdata.com'] }), ) const BetterstackToken = z.string().check( z.minLength(1), z.refine((token) => token !== redactedBetterstackToken, { error: 'A Better Stack source token is required.', }), z.describe('Your Better Stack source token (sent as `Authorization: Bearer …`).'), z.meta({ examples: ['FczKcxEhjEDE58dBX7XaeX1q'] }), ) /** * Where matched events are delivered: a `url` destination (a signed event POST * to your HTTPS endpoint), a `slack` destination (a formatted message posted to * your Slack incoming-webhook URL), or a `betterstack` destination (a structured * log event POSTed to a Better Stack source with a bearer source token). */ const Destination = z .discriminatedUnion('type', [ z.object({ type: z.literal('url'), url: Url, }), z.object({ type: z.literal('slack'), url: SlackUrl, }), z.object({ token: BetterstackToken, type: z.literal('betterstack'), url: BetterstackUrl, }), ]) .check( z.describe( 'Delivery destination: an HTTPS URL, a Slack incoming-webhook URL, or a Better Stack source.', ), ) const PublicDestination = z .discriminatedUnion('type', [ z.object({ type: z.literal('url'), url: Url, }), z.object({ type: z.literal('slack'), url: z.literal(redactedSlackUrl).check(z.describe('Redacted Slack webhook URL.')), }), z.object({ token: z .literal(redactedBetterstackToken) .check(z.describe('Redacted Better Stack source token.')), type: z.literal('betterstack'), url: BetterstackUrl, }), ]) .check(z.describe('Delivery destination with bearer credentials redacted.')) /** * Equality / membership operator for a filterable field. A consumer can pass a * bare value (shorthand for exact equality, the common case) or an operator * object: `{ eq }` exact, `{ in: [...] }` OR-list, `{ not }` negation. Used for * addresses, hashes, byte strings, enums, and raw topics. `in` lists are capped * at 64 members so a single filter can't fan out into an unbounded SQL `IN`. * * @see {@link matchesOperator} for how it is evaluated. */ export const eq = (item: item) => z.union([ item, z.strictObject({ eq: item }), z.strictObject({ in: z.array(item).check(z.minLength(1), z.maxLength(64)) }), z.strictObject({ not: item }), ]) /** * Ordered-comparison operator for a numeric field (`value`, gas/fee fields, * `nonce`, `blockNumber`, timestamps). A bare value is exact equality; the * object form AND-combines any of `eq`/`gt`/`gte`/`lt`/`lte` to express ranges. * Values are `0x`-hex quantities (the wire format), compiled to decimal in SQL. */ export const compare = z.union([ Schema.Quantity, z .strictObject({ eq: z.optional(Schema.Quantity), gt: z.optional(Schema.Quantity), gte: z.optional(Schema.Quantity), lt: z.optional(Schema.Quantity), lte: z.optional(Schema.Quantity), }) .check(z.refine((c) => Object.keys(c).length > 0, { error: 'Comparison cannot be empty.' })), ]) /** * Byte/calldata-matching operator for hex fields (tx `input`, `calls[].input`). * `{ selector }` matches the first 4 bytes (the function selector, the cheap * common case), `{ startsWith }` a hex prefix, `{ eq }` exact calldata. */ export const bytes = z.union([ z.strictObject({ selector: Schema.Hex }), z.strictObject({ startsWith: Schema.Hex }), z.strictObject({ eq: Schema.Hex }), ]) /** * `transfer` subscription filters. The predicate fields are shared verbatim * with `GET /transfers` via `Transfers.schema.Predicates`, so the two can't * drift. All are optional and AND-combined; omit every field to subscribe to * the full stream. */ export const TransferFilters = Transfers.schema.Predicates.check( z.describe('Filters that narrow which TIP-20 transfer events trigger this webhook.'), z.meta({ title: 'Transfer filters' }), ) /** * `transaction:included` subscription filters: operators over every column the * `txs` envelope exposes, plus inner account-abstraction `calls` and the * top-level `input` calldata. All optional and AND-combined; omit every field * for the full stream. * * `address` matches either side (`from` OR `to`); when `includeCalls` is set * (default), `to`/`address` also match an inner call target (`calls[].to`). */ export const TxFilters = z .strictObject({ hash: z.optional( eq(Schema.Hash).check(z.describe('Match this exact transaction hash (or any of several).')), ), address: z.optional( eq(Schema.Address).check( z.describe('Match transactions where this address is the sender or recipient.'), ), ), from: z.optional( eq(Schema.Address).check(z.describe('Match transactions whose sender (`from`) is this.')), ), to: z.optional( eq(Schema.Address).check( z.describe('Match transactions sent to this address (root `to`; see `includeCalls`).'), ), ), includeCalls: z.optional( z .boolean() .check( z.describe( 'When true (default), `to`/`address` also match an inner account-abstraction call target (`calls[].to`).', ), z.meta({ examples: [true] }), ), ), value: z.optional(compare.check(z.describe('Match the transaction value (wei) bounds.'))), input: z.optional( bytes.check( z.describe('Match top-level calldata by 4-byte `selector`, hex `startsWith`, or exact.'), ), ), calls: z.optional( z .strictObject({ to: eq(Schema.Address), }) .check( z.describe( 'Match if ANY inner account-abstraction call targets this address (`calls[].to`).', ), ), ), callCount: z.optional( compare.check(z.describe('Match the number of inner calls (`call_count`).')), ), txType: z.optional( eq(Schema.Hex).check(z.describe('Match the transaction type (e.g. `0x76` for Tempo AA).')), ), feeToken: z.optional( eq(Schema.TokenAddress).check(z.describe('Match the fee token address.')), ), gasLimit: z.optional(compare.check(z.describe('Match the gas limit bounds.'))), maxFeePerGas: z.optional(compare.check(z.describe('Match the max fee per gas bounds.'))), maxPriorityFeePerGas: z.optional( compare.check(z.describe('Match the max priority fee per gas bounds.')), ), nonce: z.optional(compare.check(z.describe('Match the nonce bounds.'))), nonceKey: z.optional(eq(Schema.Hex).check(z.describe('Match the nonce key (Tempo AA).'))), validBefore: z.optional( compare.check(z.describe('Match the `validBefore` bounds (unix s).')), ), validAfter: z.optional(compare.check(z.describe('Match the `validAfter` bounds (unix s).'))), blockNumber: z.optional(compare.check(z.describe('Match the block-number bounds.'))), timestamp: z.optional( compare.check(z.describe('Match the block timestamp bounds (unix s).')), ), }) .check( z.describe('Filters that narrow which included transactions trigger this webhook.'), z.meta({ title: 'Included-transaction filters' }), ) /** A 32-byte log topic (lowercased hex). */ const topic = Schema.Hash /** * `log:emitted` subscription filters. Raw `topic0..3` map straight to the * `logs` table; an optional human-readable ABI event `signature` is sugar that * pins `topic0` and lets `args` match decoded (indexed **or** non-indexed) * parameters by name. At least one predicate is required (no chain-wide * firehose), and an addressless filter must still be selective — an event * anchor (`signature`/`topic0`) plus at least one indexed topic or argument. */ export const LogFilters = z .strictObject({ address: z.optional( eq(Schema.Address).check( z.describe('Only match logs emitted by this contract address (or any of several).'), z.meta({ examples: ['0x20c0000000000000000000008f5425160ebe5525'] }), ), ), signature: z.optional( z.string().check( z.describe( 'Human-readable ABI event signature used to derive `topic0` and decode `args`, e.g. `event Transfer(address indexed from, address indexed to, uint256 value)`.', ), z.meta({ examples: ['event Transfer(address indexed from, address indexed to, uint256 value)'], }), ), ), topic0: z.optional( eq(topic).check( z.describe('Event signature hash (topic 0). Mutually exclusive with `signature`.'), z.meta({ examples: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'], }), ), ), topic1: z.optional( eq(topic).check( z.describe('Raw indexed argument 1 (topic 1).'), z.meta({ examples: ['0x000000000000000000000000e7687128b0a808c2831ff94d4f7b2fb35c65af38'], }), ), ), topic2: z.optional( eq(topic).check( z.describe('Raw indexed argument 2 (topic 2).'), z.meta({ examples: ['0x0000000000000000000000009e39034aae71fb89f66061a2602eb6efec271754'], }), ), ), topic3: z.optional( eq(topic).check( z.describe('Raw indexed argument 3 (topic 3).'), z.meta({ examples: ['0x0000000000000000000000000000000000000000000000000000000000000000'], }), ), ), args: z.optional( z .record(z.string(), z.unknown()) .check( z.describe( 'Match decoded event arguments by name (indexed or non-indexed). Requires `signature`. Each value is an exact value or a super admin object.', ), z.meta({ examples: [{ to: '0x9e39034aae71fb89f66061a2602eb6efec271754' }] }), ), ), blockNumber: z.optional( compare.check(z.describe('Only match logs within these block-number bounds.')), ), }) .check( z.describe('Filters that narrow which contract event logs trigger this webhook.'), z.meta({ title: 'Log filters' }), z.refine((f) => Object.keys(f).length > 0, { error: 'At least one log filter is required.', }), z.refine((f) => !(f.signature && f.topic0), { error: 'Pass `signature` or `topic0`, not both.', }), z.refine((f) => !f.args || f.signature, { error: '`args` requires `signature` to decode the event.', }), z.refine((f) => hasLogPushdown(f), { error: 'Log filters must include a pushdown-capable address or an event/topic0 anchor plus an indexed topic or argument.', }), ) /** * `block:created` subscription filters. All optional and AND-combined; unlike * `log:emitted`, an empty filter is allowed — one row per block is a bounded, * non-abusive heartbeat feed. Block-level metadata only (no transaction * predicates; use `transaction:included` for those). */ export const BlockFilters = z .strictObject({ number: z.optional( compare.check(z.describe('Only match blocks within these block-number bounds.')), ), miner: z.optional( eq(Schema.Address).check( z.describe('Only match blocks produced by this address (the miner/producer).'), z.meta({ examples: ['0x0000000000000000000000000000000000000000'] }), ), ), proposer: z.optional( eq(Schema.Address).check( z.describe('Only match blocks proposed by this consensus proposer (when available).'), ), ), gasUsed: z.optional( compare.check(z.describe('Only match blocks whose total gas used is within these bounds.')), ), gasLimit: z.optional( compare.check(z.describe('Only match blocks whose gas limit is within these bounds.')), ), timestamp: z.optional( compare.check( z.describe('Only match blocks whose unix timestamp (seconds) is within these bounds.'), ), ), }) .check( z.describe('Filters that narrow which new blocks trigger this webhook.'), z.meta({ title: 'Block filters' }), ) /** `funding:deposit.updated` subscription filters. All are optional and AND-combined. */ export const FundingDepositFilters = z .strictObject({ depositAddressId: z.optional(FundingDeposit.schema.Snapshot.shape.depositAddressId), recipient: z.optional(Schema.Address), status: z.optional(FundingDeposit.schema.Status), }) .check( z.describe('Filters that narrow which funding deposit changes trigger this webhook.'), z.meta({ title: 'Funding deposit filters' }), ) /** `funding:transfer.updated` subscription filters. All are optional and AND-combined. */ export const FundingTransferFilters = z .strictObject({ id: z.optional(FundingTransfer.schema.FundingTransfer.shape.id), status: z.optional(FundingTransfer.schema.Status), }) .check( z.describe('Filters that narrow which funding transfer changes trigger this webhook.'), z.meta({ title: 'Funding transfer filters' }), ) /** * Optional human context describing what a subscription is for. Surfaced by * destinations (Slack header + description, Better Stack log fields) and echoed * on every delivered event envelope. */ export const Context = z .object({ description: z.optional( z .string() .check( z.maxLength(500), z.describe('Longer description of what this subscription is for.'), z.meta({ examples: ['Notify #ops when a large USDC transfer settles on mainnet.'] }), ), ), metadata: z.optional( z .record( z.string().check(z.minLength(1), z.maxLength(64)), z.string().check(z.maxLength(500)), ) .check( z.refine((m) => Object.keys(m).length <= 20, { error: 'At most 20 metadata entries are allowed.', }), z.describe( 'Arbitrary key/value labels echoed on every delivered event and rendered by destinations (Slack fields, Better Stack log fields). Keys are 1–64 chars, values ≤500 chars, ≤20 entries.', ), z.meta({ examples: [{ env: 'prod', team: 'payments' }] }), ), ), title: z.optional( z .string() .check( z.maxLength(120), z.describe('Short label for this subscription.'), z.meta({ examples: ['Prod USDC large transfers'] }), ), ), }) .check(z.describe('Human context describing what this webhook subscription is for.')) /** * Public subscription representation. Never includes the signing `secret` * (returned once by `POST /webhooks`) or the internal owner. */ export const Subscription = z .object({ chainId: chainIdResponse, context: z.optional(Context), createdAt: z.iso .datetime() .check( z.describe('When the subscription was created, as an ISO 8601 timestamp.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), destination: PublicDestination, environment: z .optional(z.enum(['production', 'sandbox'])) .check( z.describe('API-key environment for private resource events, when applicable.'), z.meta({ examples: ['production'] }), ), eventType: EventType.check(z.meta({ examples: ['token:transfer'] })), expiresAt: z .optional(z.iso.datetime()) .check( z.describe( 'When the subscription expires, as an ISO 8601 timestamp. Only MPP-paid subscriptions expire.', ), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), failureCount: z .number() .check( z.int(), z.nonnegative(), z.describe( 'Number of delivery failures in a row for this subscription, capped at the auto-disable threshold.', ), z.meta({ examples: [0] }), ), filters: Filters.check( z.describe('Filters applied to this event type.'), z.meta({ examples: [{ address: '0x20c0000000000000000000008f5425160ebe5525' }] }), ), id: z .string() .check( z.describe('Webhook subscription ID (`wh_…`).'), z.meta({ examples: ['wh_001718668800000_2ZPE2gvateYEQ0dQslgvkhjx'] }), ), lastDeliveryAt: z .optional(z.iso.datetime()) .check( z.describe( 'When Tempo last delivered an event successfully, as an ISO 8601 timestamp. Refreshed at most once per minute during sustained delivery.', ), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), status: Status.check(z.meta({ examples: ['active'] })), updatedAt: z.iso .datetime() .check( z.describe('When the subscription was last changed, as an ISO 8601 timestamp.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), }) .check( z.describe( 'A webhook subscription that tells Tempo which onchain events to send to your destination.', ), ) /** Outcome of a single delivery attempt. */ export const DeliveryStatus = z .enum(['failed', 'pending', 'succeeded']) .check( z.describe('Outcome of a webhook delivery attempt.'), z.meta({ examples: ['succeeded'] }), ) /** A logged delivery attempt for a subscription. */ export const Delivery = z .object({ attempt: z .number() .check( z.int(), z.positive(), z.describe('Retry attempt number for this delivery, starting at 1.'), z.meta({ examples: [1] }), ), createdAt: z.iso .datetime() .check( z.describe('When this delivery attempt was created, as an ISO 8601 timestamp.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] }), ), error: z .optional(z.string()) .check( z.describe('Why delivery failed, present when `status` is `failed`.'), z.meta({ examples: ['Connection timed out'] }), ), eventId: z .string() .check( z.describe('Stable event ID (`evt_…`) you can use to dedupe webhook deliveries.'), z.meta({ examples: ['evt_abc123'] }), ), id: z .string() .check( z.describe('Webhook delivery ID (`whd_…`).'), z.meta({ examples: ['whd_001718668800000_C1yJkk7CuML98HFthEDk9PsY'] }), ), requestUrl: z .string() .check( z.describe('Your callback URL that Tempo attempted to deliver to.'), z.meta({ examples: ['https://example.com/webhooks'] }), ), responseMs: z .optional(z.number().check(z.int(), z.nonnegative())) .check( z.describe( 'How long the delivery attempt took in milliseconds, when Tempo made a request.', ), z.meta({ examples: [12] }), ), responseStatus: z .optional(z.number().check(z.int())) .check( z.describe('HTTP status your endpoint returned, when Tempo received a response.'), z.meta({ examples: [200] }), ), status: DeliveryStatus.check(z.meta({ examples: ['succeeded'] })), subscriptionId: z .string() .check( z.describe('Subscription ID (`wh_…`) this delivery belongs to.'), z.meta({ examples: ['wh_001718668800000_2ZPE2gvateYEQ0dQslgvkhjx'] }), ), }) .check(z.describe('One attempt by Tempo to deliver an event to your webhook URL.')) /** * A `transfer` event payload: the decoded TIP-20 `Transfer` row. Mirrors a * `GET /transfers` row but without the `sourceToken` metadata enrichment the * read endpoint adds (webhook delivery does not perform per-event token * lookups). */ export const TransferEvent = z .object({ address: Schema.TokenAddress.check( z.describe('TIP-20 token contract address.'), z.meta({ examples: ['0x20c0000000000000000000008f5425160ebe5525'] }), ), amount: z .string() .check( z.describe('Amount transferred, as a decimal integer string in the token base unit.'), z.meta({ examples: ['10000'] }), ), blockNumber: z .number() .check( z.int(), z.nonnegative(), z.describe('Block number where this transfer was included.'), z.meta({ examples: [1000002] }), ), recipient: Schema.Address.check( z.describe('Address that received the transfer.'), z.meta({ examples: ['0x9e39034aae71fb89f66061a2602eb6efec271754'] }), ), sender: Schema.Address.check( z.describe('Address that sent the transfer.'), z.meta({ examples: ['0xe7687128b0a808c2831ff94d4f7b2fb35c65af38'] }), ), timestamp: z.iso .datetime() .check( z.describe('Block timestamp as an ISO 8601 string.'), z.meta({ examples: ['2026-01-14T18:38:03.685Z'] }), ), token: z.optional( z .object({ decimals: z .number() .check( z.int(), z.nonnegative(), z.describe('Number of decimals the token uses.'), z.meta({ examples: [6] }), ), symbol: z .string() .check(z.describe('Token symbol (e.g. `USDC`).'), z.meta({ examples: ['USDC'] })), }) .check( z.describe( 'Best-effort token metadata (symbol/decimals) resolved before delivery so `amount` can render as a human amount. Omitted when the lookup is unavailable.', ), ), ), transactionHash: Schema.Hash.check( z.describe('Transaction hash that contains this transfer.'), z.meta({ examples: ['0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3'], }), ), }) .check( z.describe('Payload for a `token:transfer` event, decoded from a TIP-20 `Transfer` log.'), ) /** * A `transaction` event payload using the humanized transaction shape without * opt-in read resources such as receipts. */ export const TransactionEvent = z .object({ ...Transactions.schema.Transaction.shape, meta: z .object({ rpc: Transactions.schema.Rpc.Transaction.check( z.describe('The original JSON-RPC transaction payload.'), ), }) .check(z.describe('Metadata for this transaction webhook event.')), }) .check(z.describe('A transaction included in a block.')) /** * A `log:emitted` event payload: the raw contract event log. When a * `signature` filter was supplied and decoding succeeds, the best-effort * `event`/`args` fields carry the decoded event name and arguments (large * integers as decimal strings). */ export const LogEvent = z .object({ address: Schema.Address.check( z.describe('Contract that emitted the log.'), z.meta({ examples: ['0x20c00000000000000000000071de0cd31ab0d105'] }), ), args: z.optional( z .record(z.string(), z.unknown()) .check( z.describe( 'Decoded event arguments, present only when a `signature` filter was supplied and decoding succeeded.', ), z.meta({ examples: [{ from: '0xe768…', to: '0x9e39…', value: '10000' }] }), ), ), blockNumber: z .number() .check( z.int(), z.nonnegative(), z.describe('Block number the log was included in.'), z.meta({ examples: [1000002] }), ), data: Schema.Hex.check( z.describe('Unindexed log data (ABI-encoded).'), z.meta({ examples: ['0x0000000000000000000000000000000000000000000000000000000000002710'], }), ), event: z.optional( z .object({ name: z .string() .check(z.describe('Decoded event name.'), z.meta({ examples: ['Transfer'] })), signature: z.string().check( z.describe('Human-readable event signature used to decode the log.'), z.meta({ examples: [ 'event Transfer(address indexed from, address indexed to, uint256 value)', ], }), ), topic0: Schema.Hash.check( z.describe('Event signature hash (topic 0).'), z.meta({ examples: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'], }), ), }) .check( z.describe( 'Decoded event identity, present only when a `signature` filter was supplied.', ), ), ), logIndex: z .number() .check( z.int(), z.nonnegative(), z.describe('Index of the log within its block.'), z.meta({ examples: [0] }), ), timestamp: z.iso .datetime() .check( z.describe('When the block was produced, as an ISO 8601 timestamp.'), z.meta({ examples: ['2026-01-14T18:38:03.685Z'] }), ), topics: z.array(Schema.Hash).check( z.describe('Log topics, starting with the event signature hash (topic 0).'), z.meta({ examples: [ [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x000000000000000000000000e7687128b0a808c2831ff94d4f7b2fb35c65af38', ], ], }), ), transactionHash: Schema.Hash.check( z.describe('Hash of the transaction that emitted the log.'), z.meta({ examples: ['0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3'], }), ), transactionIndex: z .number() .check( z.int(), z.nonnegative(), z.describe('Index of the transaction within its block.'), z.meta({ examples: [0] }), ), }) .check(z.describe('Payload for a `log:emitted` event (a raw contract event log).')) /** Payload for a `block:created` event (an RPC-derived block header). */ export const BlockEvent = z .object({ number: z .number() .check( z.int(), z.nonnegative(), z.describe('Block height, starting from genesis block 0.'), z.meta({ examples: [1000002] }), ), hash: Schema.Hash.check( z.describe('Hash that identifies this block.'), z.meta({ examples: ['0x3fe7d9e595f3d8215ec840c6ef55ac0bead58933e96fc2ead0f2aaf79715f45d'], }), ), parentHash: Schema.Hash.check( z.describe('Hash of the previous block in the chain.'), z.meta({ examples: ['0xa406dff9c51cc2abbef3a811f0499e99a568bf781019f129ab45dca1df0e43e5'], }), ), miner: Schema.Address.check( z.describe('Address of the block producer (miner/proposer).'), z.meta({ examples: ['0x0000000000000000000000000000000000000000'] }), ), proposer: z.optional( Schema.Address.check( z.describe('Consensus proposer of the block, when the chain records one.'), z.meta({ examples: ['0x0000000000000000000000000000000000000000'] }), ), ), gasUsed: z .number() .check( z.int(), z.nonnegative(), z.describe('Total gas used by every transaction in this block.'), z.meta({ examples: [4340281] }), ), gasLimit: z .number() .check( z.int(), z.nonnegative(), z.describe('Maximum gas available for all transactions in this block.'), z.meta({ examples: [500000000] }), ), transactionCount: z .number() .check( z.int(), z.nonnegative(), z.describe('Number of transactions included in this block.'), z.meta({ examples: [3] }), ), timestamp: z.iso .datetime() .check( z.describe('When the block was produced, as an ISO 8601 timestamp.'), z.meta({ examples: ['2026-01-14T18:38:03.685Z'] }), ), }) .check(z.describe('Payload for a `block:created` event (a new block header).')) /** Public funding deposit after a committed material change. */ export const FundingDepositEvent = FundingDeposit.schema.FundingDeposit /** Public funding transfer after a committed material change. */ export const FundingTransferEvent = FundingTransfer.schema.FundingTransfer /** A `ping` (synthetic test-delivery) payload, sent by `POST /webhooks/:id/ping`. */ export const PingEvent = z .object({ ping: z.literal(true).check(z.describe('Marks this payload as a synthetic ping test.')), }) .check(z.describe('Payload for a webhook ping test delivery.')) /** Fields common to every delivery envelope. */ const envelopeBase = { chainId: Schema.ChainId, context: z.optional(Context), createdAt: z.iso .datetime() .check( z.describe('When Tempo built this delivery envelope, as an ISO 8601 timestamp.'), z.meta({ examples: ['2026-01-14T18:38:03.000Z'] }), ), id: z .string() .check( z.describe('Stable event ID (`evt_…`) for this delivery. Store it and ignore duplicates.'), z.meta({ examples: ['evt_abc123'] }), ), subscriptionId: z .string() .check( z.describe('Subscription ID (`wh_…`) that produced this delivery.'), z.meta({ examples: ['wh_001718668800000_2ZPE2gvateYEQ0dQslgvkhjx'] }), ), } /** * The signed JSON body POSTed to subscriber URLs, discriminated by `type`. The * `tempo-event-type` header mirrors `type`. Event variants carry the matched * event row; `ping` is a synthetic test delivery. * * Each variant carries a `title` so doc renderers (Scalar) show a labelled * variant selector — without per-variant titles, inline `oneOf` members render * as a single (first) collapsed schema. */ export const Envelope = z .discriminatedUnion('type', [ z .object({ ...envelopeBase, data: TransferEvent, type: z.literal('token:transfer') }) .check(z.meta({ title: 'Transfer event' })), z .object({ ...envelopeBase, data: TransactionEvent, type: z.literal('transaction:included'), }) .check(z.meta({ title: 'Included transaction event' })), z .object({ ...envelopeBase, data: LogEvent, type: z.literal('log:emitted') }) .check(z.meta({ title: 'Log event' })), z .object({ ...envelopeBase, data: BlockEvent, type: z.literal('block:created') }) .check(z.meta({ title: 'Block event' })), z .object({ ...envelopeBase, data: FundingDepositEvent, type: z.literal('funding:deposit.updated'), }) .check(z.meta({ title: 'Funding deposit updated event' })), z .object({ ...envelopeBase, data: FundingTransferEvent, type: z.literal('funding:transfer.updated'), }) .check(z.meta({ title: 'Funding transfer updated event' })), z .object({ ...envelopeBase, data: PingEvent, type: z.literal('ping') }) .check(z.meta({ title: 'Ping (test delivery)' })), ]) .check(z.describe('The signed JSON payload Tempo POSTs to your webhook URL.')) /** Schemas for the getWebhookEventTypes operation. */ export namespace getWebhookEventTypes { export const Response = z .object({ data: z .array( z.object({ description: z .string() .check( z.describe('Plain-English description of the event type.'), z.meta({ examples: ['TIP-20 token transfer event.'] }), ), type: EventType.check(z.meta({ examples: ['token:transfer'] })), }), ) .check(z.describe('Webhook event types available for subscription.')), }) .check(z.describe('List of webhook event types Tempo can send.')) } /** Schemas for the createWebhook operation. */ export namespace createWebhook { /** * Discriminated on `eventType` so each variant carries its own typed * `filters` object — this is what lets the docs render the valid filter * fields per event type instead of an opaque map. Variant `title`s drive the * doc renderer's selector (see {@link schema.Envelope}). */ export const Body = z .discriminatedUnion('eventType', [ z .object({ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })), context: z.optional(Context), destination: Destination, eventType: z .literal('token:transfer') .check( z.describe('Event type you want Tempo to send to your webhook URL.'), z.meta({ examples: ['token:transfer'] }), ), filters: z.optional(TransferFilters), }) .check(z.meta({ title: 'Transfer subscription' })), z .object({ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })), context: z.optional(Context), destination: Destination, eventType: z .literal('transaction:included') .check( z.describe('Event type you want Tempo to send to your webhook URL.'), z.meta({ examples: ['transaction:included'] }), ), filters: z.optional(TxFilters), }) .check(z.meta({ title: 'Included-transaction subscription' })), z .object({ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })), context: z.optional(Context), destination: Destination, eventType: z .literal('log:emitted') .check( z.describe('Event type you want Tempo to send to your webhook URL.'), z.meta({ examples: ['log:emitted'] }), ), filters: LogFilters, }) .check(z.meta({ title: 'Log subscription' })), z .object({ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })), context: z.optional(Context), destination: Destination, eventType: z .literal('block:created') .check( z.describe('Event type you want Tempo to send to your webhook URL.'), z.meta({ examples: ['block:created'] }), ), filters: z.optional(BlockFilters), }) .check(z.meta({ title: 'Block subscription' })), z .object({ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })), context: z.optional(Context), destination: Destination, eventType: z .literal('funding:deposit.updated') .check( z.describe('Event type for committed funding deposit changes.'), z.meta({ examples: ['funding:deposit.updated'] }), ), filters: z.optional(FundingDepositFilters), }) .check(z.meta({ title: 'Funding deposit subscription' })), z .object({ chainId: z.optional(Schema.ChainId).check(z.meta({ examples: [4217] })), context: z.optional(Context), destination: Destination, eventType: z .literal('funding:transfer.updated') .check( z.describe('Event type for committed funding transfer changes.'), z.meta({ examples: ['funding:transfer.updated'] }), ), filters: z.optional(FundingTransferFilters), }) .check(z.meta({ title: 'Funding transfer subscription' })), ]) .check(z.describe('Details for creating a webhook subscription.')) export const Response = z .object({ ...Subscription.shape, destination: Destination, secret: z .string() .check( z.describe( 'HMAC signing secret (`whsec_…`) used to verify Tempo webhook signatures. It is shown once when you create the webhook, so store it now.', ), z.meta({ examples: ['whsec_abc123'] }), ), }) .check(z.describe('The new webhook subscription, including the one-time signing secret.')) } /** Schemas for the listWebhooks operation. */ export namespace listWebhooks { export const Query = z .object({ cursor: Schema.Cursor, include: Schema.totalCountInclude, limit: Schema.Limit, page: Schema.Page, }) .check( ...Schema.pageChecks(), z.describe('Query parameters for listing your webhook subscriptions.'), ) export const Response = z .object({ data: z.array(Subscription).check(z.describe('Webhook subscriptions on this page.')), meta: z .optional(Schema.CountMeta) .check(z.describe('Extra response metadata requested with `include`.')), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of your webhook subscriptions.')) } /** Schemas for the getWebhook operation. */ export namespace getWebhook { export const Params = z .object({ id: z .string() .check( z.describe('Webhook subscription ID (`wh_…`).'), z.meta({ examples: ['wh_001718668800000_2ZPE2gvateYEQ0dQslgvkhjx'] }), ), }) .check(z.describe('Path parameters for a webhook subscription request.')) export const Response = Subscription } /** Schemas for the listWebhookDeliveries operation. */ export namespace listWebhookDeliveries { export const Params = getWebhook.Params export const Query = z .object({ cursor: Schema.Cursor, include: Schema.totalCountInclude, limit: Schema.Limit, page: Schema.Page, }) .check( ...Schema.pageChecks(), z.describe('Query parameters for listing webhook delivery attempts.'), ) export const Response = z .object({ data: z.array(Delivery).check(z.describe('Webhook delivery attempts on this page.')), meta: z .optional(Schema.CountMeta) .check(z.describe('Extra response metadata requested with `include`.')), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of webhook delivery attempts.')) } /** Schemas for the pingWebhook operation. */ export namespace pingWebhook { export const Params = getWebhook.Params export const Response = z .object({ delivered: z .boolean() .check( z.describe('Whether your endpoint returned a 2xx response.'), z.meta({ examples: [true] }), ), error: z .optional(z.string()) .check( z.describe('Why the delivery failed, present when `delivered` is `false`.'), z.meta({ examples: ['Connection timed out'] }), ), eventId: z .string() .check( z.describe('Synthetic event ID (`evt_…`) Tempo sent for this ping.'), z.meta({ examples: ['evt_abc123'] }), ), responseMs: z .optional(z.number().check(z.int(), z.nonnegative())) .check( z.describe('Round-trip delivery time in milliseconds, when the request completed.'), z.meta({ examples: [12] }), ), responseStatus: z .optional(z.number().check(z.int())) .check( z.describe('HTTP status your endpoint returned, when Tempo received a response.'), z.meta({ examples: [200] }), ), }) .check(z.describe('Result of sending a synthetic ping event to your webhook URL.')) } /** Schemas for the retryWebhookDelivery operation. */ export namespace retryWebhookDelivery { export const Params = z .object({ deliveryId: z .string() .check( z.describe('Delivery ID (`whd_…`) you want Tempo to replay.'), z.meta({ examples: ['whd_001718668800000_C1yJkk7CuML98HFthEDk9PsY'] }), ), id: z .string() .check( z.describe('Webhook subscription ID (`wh_…`).'), z.meta({ examples: ['wh_001718668800000_2ZPE2gvateYEQ0dQslgvkhjx'] }), ), }) .check(z.describe('Path parameters for retrying a webhook delivery.')) export const Response = pingWebhook.Response } /** Schemas for the updateWebhook operation. */ export namespace updateWebhook { export const Params = getWebhook.Params export const Body = z .object({ context: z .optional(z.nullable(Context)) .check(z.describe('New human context, or `null` to clear it.')), destination: z .optional(Destination) .check(z.describe('New delivery destination for future events (URL or Slack channel).')), filters: z .optional(Filters) .check( z.describe('New filters to apply to future webhook events.'), z.meta({ examples: [{ address: '0x20c0000000000000000000008f5425160ebe5525' }] }), ), status: z .optional(Status) .check(z.describe('New lifecycle status, such as pausing or resuming delivery.')), }) .check(z.describe('Fields you want to update on the webhook subscription.')) export const Response = Subscription } /** Schemas for the deleteWebhook operation. */ export namespace deleteWebhook { export const Params = getWebhook.Params export const Response = z .object({ id: z .string() .check( z.describe('ID of the webhook subscription that was deleted.'), z.meta({ examples: ['wh_001718668800000_2ZPE2gvateYEQ0dQslgvkhjx'] }), ), }) .check(z.describe('Confirmation that the webhook subscription was deleted.')) } } /** * Creates the webhooks resource. The subscription surface is non-public and * API-key-only for now (MPP paid access is disabled) and never edge-cached; only * `GET /webhooks/event-types` is public catalog data. Routes are always mounted * but return `404` when no state `webhook.store` is configured, and are hidden * from the OpenAPI document in that case. */ export function webhooks(options: webhooks.Options = {}) { const hidden = !options.enabled const availableEventTypes = eventTypeDescriptions.filter( (eventType) => !isFundingEventType(eventType.type) || options.applicationEventTypes?.includes(eventType.type), ) const availableEventTypeSet = new Set(availableEventTypes.map((eventType) => eventType.type)) // Gate the whole resource on a configured state store. Without it the // outbound-HTTP surface is disabled (read-only deployment). const gate = async (c: Context, next: () => Promise) => { if (!c.get('webhook')) return notEnabled(c) as never await next() // Subscriptions are principal-scoped, mutating, and stateful: never cache. if (!c.res.headers.has('Cache-Control')) c.res.headers.set('Cache-Control', 'no-store') return } return ( new Hono() // `/v1/webhooks/*` does not match the collection root, so it gets its // own registration. .use('/v1/webhooks', gate) .use('/v1/webhooks/*', gate) .get( '/v1/webhooks/event-types', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.describeRoute({ description: 'See which onchain event types Tempo can POST to your webhook URL.', hide: hidden, operationId: 'getWebhookEventTypes', responses: OpenApi.responses({ success: { description: 'Webhook event types available for subscription.', schema: schema.getWebhookEventTypes.Response, }, }), summary: 'List webhook event types', tags: ['Webhooks'], }), Cache.response({ cacheControl: Cache.policies.stable, name: 'tempo-api:webhooks:v1', key: (c) => { const url = new URL(c.req.url) url.search = '' return url.toString() }, }), (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (!c.get('webhook')) return notEnabled(c) return c.json( Response.validated(schema.getWebhookEventTypes.Response, { data: availableEventTypes.map((eventType) => ({ ...eventType })), }), 200, ) }, ) .post( '/v1/webhooks', Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }), OpenApi.validate('json', schema.createWebhook.Body, { code: 'body_invalid', message: 'Check the request body and try again.', }), OpenApi.describeRoute({ description: 'Create a webhook subscription so Tempo can POST signed onchain events to your URL. The signing secret is shown only once, and each call creates a separate subscription.', hide: hidden, operationId: 'createWebhook', responses: OpenApi.responses({ errors: { 400: { codes: [ 'body_invalid', 'chain_id_unsupported', 'event_type_unsupported', 'filters_invalid', 'url_invalid', ], description: 'The request, destination, filters, or chain are invalid.', }, 403: { codes: ['api_key_forbidden', 'limit_exceeded'], description: 'The API key lacks access or the subscription limit was reached.', }, }, success: { description: 'The created webhook subscription, including the one-time signing secret.', schema: schema.createWebhook.Response, }, }), summary: 'Create webhook', tags: ['Webhooks'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (!c.get('webhook')) return notEnabled(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'body_invalid', message: 'Check the request body and try again.', }) const webhook = c.get('webhook')! const db = Db.get(c.get('db')) const principal = Auth.getPrincipal(c) const owner = ownerFor(c) if (!owner) return unauthorized(c) const body = c.req.valid('json') if (!availableEventTypeSet.has(body.eventType)) return Response.error(c, { code: 'event_type_unsupported', message: 'Webhook event type is not available in this deployment', status: 400, }) if (isFundingEventType(body.eventType)) { if (principal?.type !== 'api_key') return unauthorized(c) if ( !principal.apiKey.scopes.some( (scope) => scope === Scope.wildcard || scope === 'funding:read', ) ) return Response.error(c, { code: 'api_key_forbidden', message: 'API key missing required scope', status: 403, }) } const chainId = body.chainId ?? c.get('chainId') const supportedChainIds = new Set(webhook.supportedChainIds) if (!supportedChainIds.has(chainId)) return Response.unsupportedChainId(c, chainId, supportedChainIds) try { // Preserve destination errors before the creation-time RPC dependency. WebhookDestination.assertDestination(body.destination) const maxPerOwner = webhook.maxPerOwner ?? defaultMaxPerOwner if ((await Webhooks.countSubscriptions(db, owner)) >= maxPerOwner) throw new Webhooks.LimitExceededError(maxPerOwner) const startBlockNumber = isFundingEventType(body.eventType) ? undefined : await getRpcHead(c.get('getClient')(chainId)) const subscription = await Webhooks.createSubscription( db, { chainId, ...(body.context === undefined ? {} : { context: body.context }), destination: body.destination, ...(principal?.type === 'api_key' ? { environment: principal.environment, ...(principal.projectId === undefined ? {} : { projectId: principal.projectId }), } : {}), eventType: body.eventType, filters: body.filters, owner, ...(owner.type === 'mpp' ? { ttl: mppTtlMs } : {}), }, { maxPerOwner, ...(startBlockNumber === undefined ? {} : { startBlockNumber }), }, ) return c.json( Response.validated(schema.createWebhook.Response, toCreated(subscription)), 200, ) } catch (cause) { return createMutationError(c, cause) } }, ) .get( '/v1/webhooks', Auth.policy({ apiKey: { scopes: ['webhooks:read'] } }), OpenApi.validate('query', schema.listWebhooks.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ description: 'List your webhook subscriptions.', hide: hidden, operationId: 'listWebhooks', responses: OpenApi.responses({ success: { description: 'A page of webhook subscriptions.', schema: schema.listWebhooks.Response, }, }), summary: 'List webhooks', tags: ['Webhooks'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (!c.get('webhook')) return notEnabled(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const db = Db.get(c.get('db')) const owner = ownerFor(c) if (!owner) return unauthorized(c) const access = accessFor(c) const { cursor, include, limit, page } = c.req.valid('query') try { // `totalCount` is opt-in and exact (the per-owner set is bounded); // run it concurrently with the page list. Best-effort: a failure // omits `meta` rather than failing the page. const countPromise = include.includes('totalCount') ? Webhooks.countSubscriptions(db, owner, { access }).catch(() => undefined) : undefined // Fetch one extra to detect a further page without a second round-trip. // `page` translates to a positional slice of `(page - 1) * limit` rows. const rows = await Webhooks.listSubscriptions(db, owner, { access, cursor, limit: limit + 1, offset: page !== undefined && page > 1 ? (page - 1) * limit : undefined, }) const hasMore = rows.length > limit const data = (hasMore ? rows.slice(0, limit) : rows).map(toPublic) const nextCursor = hasMore ? (data[data.length - 1]?.id ?? null) : null const totalCount = countPromise ? await countPromise : undefined return c.json( Response.validated(schema.listWebhooks.Response, { data, ...(totalCount !== undefined ? { meta: { totalCountCapped: false, totalCount } } : {}), nextCursor, }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) // The `:id` param is constrained to the `wh_…` id shape so it can't also // match the sibling static route `GET /webhooks/event-types`. Without the // constraint, this API-key-only route's policy would be collected for the // event-types request and override its (intentionally public) access. .get( '/v1/webhooks/:id{wh_[A-Za-z0-9_-]+}', Auth.policy({ apiKey: { scopes: ['webhooks:read'] } }), OpenApi.validate('param', schema.getWebhook.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ description: 'Get one webhook subscription by its ID.', hide: hidden, operationId: 'getWebhook', responses: OpenApi.responses({ errors: { 404: { description: 'No webhook subscription was found for that ID.', codes: ['webhook_not_found'], }, }, success: { description: 'The requested webhook subscription.', schema: schema.getWebhook.Response, }, }), summary: 'Get webhook', tags: ['Webhooks'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (!c.get('webhook')) return notEnabled(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) const db = Db.get(c.get('db')) const owner = ownerFor(c) if (!owner) return unauthorized(c) const access = accessFor(c) const { id } = c.req.valid('param') try { const subscription = await Webhooks.getSubscription(db, owner, id, { access }) if (!subscription) return notFound(c) return c.json( Response.validated(schema.getWebhook.Response, toPublic(subscription)), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/v1/webhooks/:id{wh_[A-Za-z0-9_-]+}/deliveries', Auth.policy({ apiKey: { scopes: ['webhooks:read'] } }), OpenApi.validate('param', schema.listWebhookDeliveries.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('query', schema.listWebhookDeliveries.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ description: 'List delivery attempts for a webhook subscription.', hide: hidden, operationId: 'listWebhookDeliveries', responses: OpenApi.responses({ errors: { 404: { description: 'No webhook subscription was found for that ID.', codes: ['webhook_not_found'], }, }, success: { description: 'A newest-first page of webhook delivery attempts.', schema: schema.listWebhookDeliveries.Response, }, }), summary: 'List webhook deliveries', tags: ['Webhooks'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (!c.get('webhook')) return notEnabled(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const db = Db.get(c.get('db')) const owner = ownerFor(c) if (!owner) return unauthorized(c) const access = accessFor(c) const { id } = c.req.valid('param') const { cursor, include, limit, page } = c.req.valid('query') try { // Owner-scope first: resolving the subscription under the caller's owner // key 404s ids they don't own (indistinguishable from "doesn't exist"). const subscription = await Webhooks.getSubscription(db, owner, id, { access }) if (!subscription) return notFound(c) // `totalCount` is opt-in and exact (the delivery log is TTL-bounded); // run it concurrently with the page list. Best-effort: a failure // omits `meta` rather than failing the page. const countPromise = include.includes('totalCount') ? Webhooks.countDeliveries(db, id).catch(() => undefined) : undefined // Fetch one extra to detect a further page without a second round-trip. // `page` translates to a positional slice of `(page - 1) * limit` rows. const rows = await Webhooks.listDeliveries(db, id, { cursor, limit: limit + 1, offset: page !== undefined && page > 1 ? (page - 1) * limit : undefined, }) const hasMore = rows.length > limit const data = hasMore ? rows.slice(0, limit) : rows const nextCursor = hasMore ? (data[data.length - 1]?.id ?? null) : null const totalCount = countPromise ? await countPromise : undefined return c.json( Response.validated(schema.listWebhookDeliveries.Response, { data, ...(totalCount !== undefined ? { meta: { totalCountCapped: false, totalCount } } : {}), nextCursor, }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/webhooks/:id{wh_[A-Za-z0-9_-]+}/ping', Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }), OpenApi.validate('param', schema.pingWebhook.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ description: 'Send a signed `ping` event to your webhook URL to test delivery and signature verification.', hide: hidden, operationId: 'pingWebhook', responses: OpenApi.responses({ errors: { 404: { description: 'No webhook subscription was found for that ID.', codes: ['webhook_not_found'], }, }, success: { description: 'Result of the synthetic ping delivery.', schema: schema.pingWebhook.Response, }, }), summary: 'Ping webhook', tags: ['Webhooks'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (!c.get('webhook')) return notEnabled(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) const db = Db.get(c.get('db')) const owner = ownerFor(c) if (!owner) return unauthorized(c) const access = accessFor(c) const { id } = c.req.valid('param') try { // Owner-scope first so unowned/unknown ids are indistinguishable (404). const subscription = await Webhooks.getSubscription(db, owner, id, { access }) if (!subscription) return notFound(c) // Inline delivery (not the queue) so the caller gets an immediate // result. `ping` never mutates the subscription's failure/lifecycle // state — a test must not disable a healthy endpoint. const { envelope, result } = await Webhooks.ping(db, subscription) return c.json( Response.validated(schema.pingWebhook.Response, { delivered: result.ok, eventId: envelope.id, ...(result.error === undefined ? {} : { error: result.error }), ...(result.durationMs === undefined ? {} : { responseMs: result.durationMs }), ...(result.status === undefined ? {} : { responseStatus: result.status }), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .post( '/v1/webhooks/:id{wh_[A-Za-z0-9_-]+}/deliveries/:deliveryId/retry', Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }), OpenApi.validate('param', schema.retryWebhookDelivery.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ description: 'Replay a previous webhook delivery and update the delivery result.', hide: hidden, operationId: 'retryWebhookDelivery', responses: OpenApi.responses({ errors: { 404: { description: 'No webhook subscription or delivery was found for those IDs.', codes: ['webhook_not_found', 'delivery_not_found'], }, }, success: { description: 'Result of replaying the webhook delivery.', schema: schema.retryWebhookDelivery.Response, }, }), summary: 'Retry webhook delivery', tags: ['Webhooks'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (!c.get('webhook')) return notEnabled(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) const db = Db.get(c.get('db')) const owner = ownerFor(c) if (!owner) return unauthorized(c) const access = accessFor(c) const { deliveryId, id } = c.req.valid('param') try { // Owner-scope the subscription first so unowned ids 404 like elsewhere. const subscription = await Webhooks.getSubscription(db, owner, id, { access }) if (!subscription) return notFound(c) const delivery = await Webhooks.getDelivery(db, id, deliveryId) if (!delivery) return Response.error(c, { code: 'delivery_not_found', message: 'Delivery not found', status: 404, }) // Replay the exact persisted envelope inline. Unlike `ping`, this is a // real event, so the outcome updates the subscription's failure state. const result = await Webhooks.deliverAndRecord(db, subscription, delivery.envelope) return c.json( Response.validated(schema.retryWebhookDelivery.Response, { delivered: result.ok, eventId: delivery.envelope.id, ...(result.error === undefined ? {} : { error: result.error }), ...(result.durationMs === undefined ? {} : { responseMs: result.durationMs }), ...(result.status === undefined ? {} : { responseStatus: result.status }), }), 200, ) } catch (cause) { return Response.upstream(c, cause) } }, ) .patch( '/v1/webhooks/:id{wh_[A-Za-z0-9_-]+}', Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }), OpenApi.validate('param', schema.updateWebhook.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.validate('json', schema.updateWebhook.Body, { code: 'body_invalid', message: 'Check the request body and try again.', }), OpenApi.describeRoute({ description: 'Update a webhook subscription URL, filters, or delivery status.', hide: hidden, operationId: 'updateWebhook', responses: OpenApi.responses({ errors: { 400: { codes: [ 'body_invalid', 'destination_transition_invalid', 'filters_invalid', 'param_invalid', 'url_invalid', ], description: 'The update, destination, or filters are invalid.', }, 404: { description: 'No webhook subscription was found for that ID.', codes: ['webhook_not_found'], }, }, success: { description: 'The updated webhook subscription.', schema: schema.updateWebhook.Response, }, }), summary: 'Update webhook', tags: ['Webhooks'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (!c.get('webhook')) return notEnabled(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'body_invalid', message: 'Check the request body and try again.', }) const db = Db.get(c.get('db')) const owner = ownerFor(c) if (!owner) return unauthorized(c) const access = accessFor(c) const { id } = c.req.valid('param') const patch = c.req.valid('json') try { // The PATCH body accepts an opaque `filters` map (it can't know the // subscription's event type up front), so revalidate the incoming // filters against the stored `eventType` and fail closed before // persisting — otherwise a bad filter would silently widen the match // set at scan time. if (patch.filters !== undefined) { const existing = await Webhooks.getSubscription(db, owner, id, { access }) if (!existing) return notFound(c) parseFilters(existing.eventType, patch.filters) } const subscription = await Webhooks.updateSubscription(db, owner, id, patch, { access }) if (!subscription) return notFound(c) return c.json( Response.validated(schema.updateWebhook.Response, toPublic(subscription)), 200, ) } catch (cause) { return updateMutationError(c, cause) } }, ) .delete( '/v1/webhooks/:id{wh_[A-Za-z0-9_-]+}', Auth.policy({ apiKey: { scopes: ['webhooks:write'] } }), OpenApi.validate('param', schema.deleteWebhook.Params, { code: 'param_invalid', message: 'Check the path parameters and try again.', }), OpenApi.describeRoute({ description: 'Delete a webhook subscription and stop future deliveries immediately.', hide: hidden, operationId: 'deleteWebhook', responses: OpenApi.responses({ errors: { 404: { description: 'No webhook subscription was found for that ID.', codes: ['webhook_not_found'], }, }, success: { description: 'Confirmation that the webhook subscription was deleted.', schema: schema.deleteWebhook.Response, }, }), summary: 'Delete webhook', tags: ['Webhooks'], }), async (c) => { if (Auth.narrowAccess) return Auth.accessError(c) if (!c.get('webhook')) return notEnabled(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'param_invalid', message: 'Check the path parameters and try again.', }) const db = Db.get(c.get('db')) const owner = ownerFor(c) if (!owner) return unauthorized(c) const access = accessFor(c) const { id } = c.req.valid('param') try { const deleted = await Webhooks.deleteSubscription(db, owner, id, { access }) if (!deleted) return notFound(c) return c.json(Response.validated(schema.deleteWebhook.Response, { id }), 200) } catch (cause) { return Response.upstream(c, cause) } }, ) ) } export declare namespace webhooks { /** Options for creating webhook routes. */ type Options = { /** Application events produced by this deployment outside the per-block chain scan. */ applicationEventTypes?: readonly Webhooks.EventType[] | undefined /** Whether webhook state is configured and routes should appear in OpenAPI. */ enabled?: boolean | undefined } } /** * Attaches best-effort `{ symbol, decimals }` token metadata to a * `token:transfer` row so destinations can render human amounts. Reads the row's * `address` (the TIP-20 contract), resolves metadata via a memoized RPC lookup, * and returns the row unchanged when the address is missing or the lookup fails. */ async function withTokenMetadata( data: unknown, deps: { cache: Store.Store chainId: number client: Viem.getClient.ReturnType }, ): Promise { if (typeof data !== 'object' || data === null) return data const address = (data as Record)['address'] if (typeof address !== 'string') return data const token = await resolveTokenMetadata(address, deps) return token ? { ...(data as Record), token: { decimals: token.decimals, symbol: token.symbol }, } : data } /** Core RPC metadata needed by a humanized token reference. */ type TokenMetadata = { currency: string decimals: number name: string symbol: string } /** * Resolves core token metadata via `token.getMetadata`, memoized per * `(chainId, address)` with a short TTL so a busy transfer stream shares one RPC * lookup per token. Returns `undefined` on any failure; metadata is a rendering * nicety, never a delivery blocker. */ async function resolveTokenMetadata( address: string, deps: { cache: Store.Store; chainId: number; client: Viem.getClient.ReturnType }, ): Promise { try { return await Store.memoize( async () => { const meta = await deps.client.token.getMetadata({ token: address as `0x${string}` }) return { currency: meta.currency, decimals: meta.decimals, name: meta.name, symbol: meta.symbol, } }, { key: `webhook:token-meta:v2:${deps.chainId}:${address}`, store: deps.cache, ttl: Ttl.minutes(5), }, ) } catch { // Metadata is best-effort; fall back to base-unit rendering. return undefined } } /** Returns the address from an unenriched or enriched fee-token value. */ function feeTokenAddress(value: unknown) { if (typeof value === 'string') return value if (typeof value !== 'object' || value === null) return undefined const address = (value as Record)['address'] return typeof address === 'string' ? address : undefined } /** Replaces an address-valued fee token with its resolved reference, or omits it on failure. */ function withResolvedFeeToken(data: Record, metadata: Map) { const address = feeTokenAddress(data['feeToken']) if (!address) return data const result = { ...data } delete result['feeToken'] const token = metadata.get(address.toLowerCase()) if (token) result['feeToken'] = { address, ...token } return result } /** Adds a best-effort fee-token reference to a transaction. */ async function withFeeTokenMetadata( data: unknown, deps: { cache: Store.Store chainId: number client: Viem.getClient.ReturnType }, ): Promise { if (typeof data !== 'object' || data === null) return data const transaction = data as Record const address = feeTokenAddress(transaction['feeToken']) if (!address) return data const token = await resolveTokenMetadata(address, deps) const metadata = new Map() if (token) metadata.set(address.toLowerCase(), token) return withResolvedFeeToken(transaction, metadata) } /** Returns the live RPC head block number for the chain. */ export async function getRpcHead(client: Viem.getClient.ReturnType): Promise { return Number(await client.getBlockNumber()) } const filterSchemas = { 'block:created': schema.BlockFilters, 'funding:deposit.updated': schema.FundingDepositFilters, 'funding:transfer.updated': schema.FundingTransferFilters, 'log:emitted': schema.LogFilters, 'token:transfer': schema.TransferFilters, 'transaction:included': schema.TxFilters, } satisfies Record /** * Validates `filters` against the schema for `eventType`, **failing closed**: an * invalid filter throws {@link Webhooks.InvalidFilterError} instead of degrading * to `{}` (a firehose). Used at create/patch time and by {@link scanBlock} so a stored * bad filter can never silently widen the match set. */ export function parseFilters( eventType: EventType, filters: Record | undefined, ): Record { const result = filterSchemas[eventType].safeParse(filters ?? {}) if (!result.success) throw new Webhooks.InvalidFilterError( eventType, Response.validationDetails(result.error.issues), ) return result.data as Record } /** Recursively lowercases string leaves of a filter operator (for address args). */ function lowercaseLeaves(op: unknown): unknown { if (typeof op === 'string') return op.toLowerCase() if (Array.isArray(op)) return op.map(lowercaseLeaves) if (op && typeof op === 'object') { const out: Record = {} for (const [key, value] of Object.entries(op)) out[key] = lowercaseLeaves(value) return out } return op } /** Recursively renders decoded event args JSON-safe (bigint → decimal string). */ function serializeArgs(value: unknown): unknown { if (typeof value === 'bigint') return value.toString() if (Array.isArray(value)) return value.map(serializeArgs) if (value && typeof value === 'object') { const out: Record = {} for (const [key, inner] of Object.entries(value)) out[key] = serializeArgs(inner) return out } return value } // Shared filter matching (JS predicates over RPC objects). /** Parses a `0x`-hex (or decimal) quantity into a bigint for numeric comparison. */ const toBig = (value: unknown) => BigInt(String(value)) /** Case-insensitive equality for string fields; bigint equality for numeric. */ function operandEquals(value: string, target: unknown, kind: 'number' | 'string'): boolean { return kind === 'number' ? toBig(value) === toBig(target) : value.toLowerCase() === String(target).toLowerCase() } /** * The JS counterpart of the old SQL `applyOperator`: evaluates one parsed filter * operator (from {@link schema.eq}/{@link schema.compare}/{@link schema.bytes}) * against a single `0x`-hex field value pulled from an RPC object. The one place * operator semantics live, shared by every event matcher. Returns `false` for a * missing field so an absent value never matches (fail-closed). * * - bare value / `{ eq }` → equality * - `{ in: [...] }` → membership * - `{ not }` → inequality * - `{ gt|gte|lt|lte }` → numeric range (AND-combined) * - `{ selector }` → first 4 bytes (10 hex chars) equal * - `{ startsWith }` → hex prefix */ export function matchesOperator( value: string | undefined, op: unknown, kind: 'number' | 'string' = 'string', ): boolean { if (value === undefined) return false if (typeof op !== 'object' || op === null) return operandEquals(value, op, kind) const o = op as Record if ('selector' in o) return value.slice(0, 10).toLowerCase() === String(o['selector']).toLowerCase() if ('startsWith' in o) return value.toLowerCase().startsWith(String(o['startsWith']).toLowerCase()) if ('in' in o) return (o['in'] as readonly unknown[]).some((v) => operandEquals(value, v, kind)) if ('not' in o) return !operandEquals(value, o['not'], kind) if ('eq' in o && kind === 'string') return operandEquals(value, o['eq'], kind) // Comparison object: AND-combine whichever of eq/gt/gte/lt/lte are present. const n = toBig(value) let ok = true if ('eq' in o) ok = ok && n === toBig(o['eq']) if ('gt' in o) ok = ok && n > toBig(o['gt']) if ('gte' in o) ok = ok && n >= toBig(o['gte']) if ('lt' in o) ok = ok && n < toBig(o['lt']) if ('lte' in o) ok = ok && n <= toBig(o['lte']) return ok } /** Matches an `eq`-operator against any inner account-abstraction call target. */ function matchesCalls(calls: unknown, op: unknown): boolean { const targets = (Array.isArray(calls) ? calls : []) .map((c) => (c && typeof c === 'object' ? (c as Record)['to'] : undefined)) .filter((to): to is string => typeof to === 'string') .map((to) => to.toLowerCase()) const has = (addr: unknown) => targets.includes(String(addr).toLowerCase()) if (typeof op === 'object' && op !== null) { const o = op as Record if ('in' in o) return (o['in'] as readonly unknown[]).some(has) if ('not' in o) return !has(o['not']) if ('eq' in o) return has(o['eq']) } return has(op) } /** A viem-formatted block header (no transactions), as returned by `getBlock`. */ type RpcBlock = Awaited> // log:emitted /** * A viem-shaped log consumed by the log matchers. `args`/`eventName` are present * once {@link decodeScanLog} best-effort decodes the log against a subscription * event signature. */ type ScanLog = { address: string args?: Record | readonly unknown[] blockNumber: bigint | null data: string eventName?: string logIndex: number | null topics: readonly string[] transactionHash: string | null transactionIndex: number | null } /** The raw + best-effort-decoded payload for one `log:emitted` row. */ type LogEventData = { address: string args?: Record blockNumber: number data: string event?: { name: string; signature: string; topic0: string } logIndex: number timestamp: string topics: string[] transactionHash: string transactionIndex: number } /** * Builds the delivered `log:emitted` payload from a viem-shaped log plus the * block timestamp. Returns `undefined` when a required field is missing/invalid * (the row is skipped). Decoded `args`/`eventName` are serialized when present. */ function toLogEvent( log: ScanLog, timestamp: string | undefined, signature: string | undefined, event: AbiEvent | undefined, ): LogEventData | undefined { const address = Schema.Address.safeParse(log.address) const transactionHash = Schema.Hash.safeParse(log.transactionHash) const blockNumber = log.blockNumber === null ? undefined : Number(log.blockNumber) const logIndex = log.logIndex ?? undefined const transactionIndex = log.transactionIndex ?? undefined const topics = log.topics.filter((t): t is string => typeof t === 'string') const topic0 = Schema.Hash.safeParse(topics[0]) const data = typeof log.data === 'string' ? log.data : '0x' if ( !address.success || !transactionHash.success || !topic0.success || blockNumber === undefined || logIndex === undefined || transactionIndex === undefined || timestamp === undefined ) return undefined const base: LogEventData = { address: address.data, blockNumber, data, logIndex, timestamp, topics, transactionHash: transactionHash.data, transactionIndex, } if (!signature || !event || log.args === undefined) return base return { ...base, args: serializeArgs(log.args) as Record, event: { name: event.name, signature, topic0: topic0.data }, } } /** Narrows an exact or membership operator into an RPC filter value. */ function pushValue(op: unknown): unknown { const value = (() => { if (typeof op !== 'object' || op === null) return op const operator = op as Record if ('eq' in operator) return operator['eq'] if (Array.isArray(operator['in']) && operator['in'].length > 0) return operator['in'] return undefined })() const scalar = (item: unknown) => ['bigint', 'boolean', 'number', 'string'].includes(typeof item) if (scalar(value)) return value if (Array.isArray(value) && value.length > 0 && value.every(scalar)) return value return undefined } /** Narrows an `eq`/`in` operator into a hex RPC filter value. */ function pushHex(op: unknown): `0x${string}` | `0x${string}`[] | undefined { const value = pushValue(op) if (typeof value === 'string') return value as `0x${string}` if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value as `0x${string}`[] return undefined } /** Narrows an `eq`/`in` address operator into a pushdown-capable address value. */ function pushAddress(op: unknown) { return pushHex(op) } /** Parses a human-readable event signature without letting refinements throw. */ function parseEvent(signature: string | undefined): AbiEvent | undefined { if (!signature) return undefined try { const item = parseAbiItem(signature) return item.type === 'event' ? item : undefined } catch { return undefined } } /** Returns exact indexed arguments usable as a selective log anchor. */ function pushEventArgs(args: Record | undefined, event: AbiEvent) { const pushed: Record = {} for (const [name, op] of Object.entries(args ?? {})) { const input = event.inputs.find((item) => item.indexed && item.name === name) const value = pushValue(op) if (input && value !== undefined) pushed[name] = value } return pushed } /** Checks that the accepted filter carries a selective anchor rather than matching every log. */ function hasLogPushdown(f: z.output): boolean { if (pushAddress(f.address) !== undefined) return true const secondaryTopic = [f.topic1, f.topic2, f.topic3].some( (topic) => pushHex(topic) !== undefined, ) const event = parseEvent(f.signature) if (event && !event.anonymous) return secondaryTopic || Object.keys(pushEventArgs(f.args, event)).length > 0 return pushHex(f.topic0) !== undefined && secondaryTopic } /** Converts one raw JSON-RPC log into the viem-shaped matcher input. */ function fromRpcLog(log: RpcLog): ScanLog { return { address: log.address, blockNumber: log.blockNumber === null ? null : BigInt(log.blockNumber), data: log.data, logIndex: Value.hexToNumber(log.logIndex) ?? null, topics: log.topics, transactionHash: log.transactionHash, transactionIndex: Value.hexToNumber(log.transactionIndex) ?? null, } } /** Best-effort decodes a raw log using the subscription event. */ function decodeScanLog(log: ScanLog, event: AbiEvent): ScanLog { try { const decoded = decodeEventLog({ abi: [event], data: log.data as `0x${string}`, strict: false, topics: log.topics as [`0x${string}`, ...`0x${string}`[]], }) return { ...log, args: decoded.args, eventName: decoded.eventName, } } catch { return log } } /** * Attaches best-effort token metadata to one matched row before delivery. * Transfers gain display metadata; transactions resolve their fee token. */ export async function enrichEventData( eventType: EventType, data: unknown, options: enrichEventData.Options, ): Promise { if (eventType === 'token:transfer') return withTokenMetadata(data, options) if (eventType === 'transaction:included') return withFeeTokenMetadata(data, options) return data } export declare namespace enrichEventData { /** Metadata lookup dependencies. */ type Options = { /** Memoization store for token metadata (5 minute TTL). */ cache: Store.Store /** Chain the row was produced on. */ chainId: number /** Client for `token.getMetadata` reads. */ client: Viem.getClient.ReturnType } } /** Re-checks all log filters (including non-pushable operators) in JS. */ function matchesLog( log: ScanLog, f: z.output, event: AbiEvent | undefined, ): boolean { const topics = log.topics if (event) { const selector = encodeEventTopics({ abi: [event] })[0] if (topics[0]?.toLowerCase() !== selector.toLowerCase()) return false } if (f.address !== undefined && !matchesOperator(log.address, f.address)) return false if ( f.blockNumber !== undefined && !matchesOperator(log.blockNumber?.toString(), f.blockNumber, 'number') ) return false // With a signature, topic0 is anchored on the event selector above, so the // raw `topic0` operator only applies in raw (no-signature) mode. if (!event && f.topic0 !== undefined && !matchesOperator(topics[0], f.topic0)) return false if (f.topic1 !== undefined && !matchesOperator(topics[1], f.topic1)) return false if (f.topic2 !== undefined && !matchesOperator(topics[2], f.topic2)) return false if (f.topic3 !== undefined && !matchesOperator(topics[3], f.topic3)) return false if (event && f.args && Object.keys(f.args).length > 0) { // Best-effort decoding leaves a non-decodable log without `args`. if (log.args === undefined || Array.isArray(log.args)) return false const args = log.args as Record for (const [name, op] of Object.entries(f.args)) { const input = event.inputs.find((i) => i.name === name) if (!input) throw new Webhooks.InvalidFilterError('log:emitted', [ { message: `Unknown event argument: ${name}.`, path: ['args', name] }, ]) const numeric = /^u?int/.test(input.type) const value = args[name] const normalized = input.type === 'address' ? lowercaseLeaves(op) : op if (!matchesOperator(asString(value), normalized, numeric ? 'number' : 'string')) return false } } return true } // block:created type BlockEventData = { number: number hash: string parentHash: string miner: string gasUsed: number gasLimit: number transactionCount: number timestamp: string } /** Builds the delivered `block:created` payload from a viem block header. */ function toBlockEvent(block: RpcBlock): BlockEventData | undefined { const number = block.number === null ? undefined : Number(block.number) const hash = Schema.Hash.safeParse(block.hash) const parentHash = Schema.Hash.safeParse(block.parentHash) const miner = Schema.Address.safeParse(block.miner) const gasUsed = typeof block.gasUsed === 'bigint' ? Number(block.gasUsed) : undefined const gasLimit = typeof block.gasLimit === 'bigint' ? Number(block.gasLimit) : undefined const timestamp = Value.blockToIso(block) const transactionCount = Array.isArray(block.transactions) ? block.transactions.length : 0 if ( number === undefined || !hash.success || !parentHash.success || !miner.success || gasUsed === undefined || gasLimit === undefined || timestamp === undefined ) return undefined return { gasLimit, gasUsed, hash: hash.data, miner: miner.data, number, parentHash: parentHash.data, timestamp, transactionCount, } } /** Re-checks all block filters in JS. The RPC has no consensus `proposer`. */ function matchesBlock(block: RpcBlock, f: z.output): boolean { if (f.number !== undefined && !matchesOperator(block.number?.toString(), f.number, 'number')) return false if (f.miner !== undefined && !matchesOperator(block.miner, f.miner)) return false if (f.gasUsed !== undefined && !matchesOperator(block.gasUsed?.toString(), f.gasUsed, 'number')) return false if ( f.gasLimit !== undefined && !matchesOperator(block.gasLimit?.toString(), f.gasLimit, 'number') ) return false if ( f.timestamp !== undefined && !matchesOperator(block.timestamp?.toString(), f.timestamp, 'number') ) return false // `proposer` is not exposed over standard RPC, so it can never match. if (f.proposer !== undefined) return false return true } // transaction:included /** Maps a `transaction:included` scalar filter field to its raw-tx field + kind. */ const txFields = { blockNumber: { field: 'blockNumber', kind: 'number' }, feeToken: { field: 'feeToken', kind: 'string' }, gasLimit: { field: 'gas', kind: 'number' }, hash: { field: 'hash', kind: 'string' }, maxFeePerGas: { field: 'maxFeePerGas', kind: 'number' }, maxPriorityFeePerGas: { field: 'maxPriorityFeePerGas', kind: 'number' }, nonce: { field: 'nonce', kind: 'number' }, nonceKey: { field: 'nonceKey', kind: 'string' }, txType: { field: 'type', kind: 'string' }, validAfter: { field: 'validAfter', kind: 'number' }, validBefore: { field: 'validBefore', kind: 'number' }, value: { field: 'value', kind: 'number' }, } as const satisfies Record /** Re-checks transaction filters against the raw RPC transaction. */ function matchesTx( tx: Record, f: z.output, includeCalls: boolean, timestamp: string | undefined, blockNumber: number, ): boolean { const from = tx['from'] const to = tx['to'] const calls = tx['calls'] if (f.from !== undefined && !matchesOperator(asString(from), f.from)) return false if (f.to !== undefined) { const direct = matchesOperator(asString(to), f.to) if (!direct && !(includeCalls && matchesCalls(calls, f.to))) return false } if (f.address !== undefined) { const direct = matchesOperator(asString(from), f.address) || matchesOperator(asString(to), f.address) if (!direct && !(includeCalls && matchesCalls(calls, f.address))) return false } if (f.calls?.to !== undefined && !matchesCalls(calls, f.calls.to)) return false if (f.input !== undefined && !matchesOperator(asString(tx['input']), f.input)) return false if (f.callCount !== undefined) { const count = Array.isArray(calls) ? calls.length : 0 if (!matchesOperator(String(count), f.callCount, 'number')) return false } for (const [name, spec] of Object.entries(txFields)) { const op = (f as Record)[name] if (op === undefined) continue const value = name === 'blockNumber' ? (asString(tx['blockNumber']) ?? blockNumber.toString()) : asString(tx[spec.field]) if (!matchesOperator(value, op, spec.kind)) return false } if (f.timestamp !== undefined && !matchesOperator(timestamp, f.timestamp, 'number')) return false return true } /** Coerces an RPC field to a string for matching, or `undefined` when absent. */ function asString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } function ownerFor(c: Context): Webhooks.Owner | null { const principal = Auth.getPrincipal(c) if (!principal) return null if (principal.type === 'api_key') return { orgId: principal.orgId, type: 'api_key' } const payer = principal.payment?.payer return payer ? { payer, type: 'mpp' } : null } function accessFor(c: Context): Webhooks.Access { const principal = Auth.getPrincipal(c) if (principal?.type !== 'api_key') throw new Error('Webhook routes require an API key.') return { environment: principal.environment, ...(principal.projectId === undefined ? {} : { projectId: principal.projectId }), scopes: principal.apiKey.scopes, } } /** Strips internal fields (`owner`, `secret`) from a subscription for reads. */ function toPublic(subscription: Webhooks.Subscription) { return { chainId: subscription.chainId, ...(subscription.context === undefined ? {} : { context: subscription.context }), createdAt: subscription.createdAt, destination: redactDestination(subscription.destination), ...(isFundingEventType(subscription.eventType) && subscription.environment !== undefined ? { environment: subscription.environment } : {}), eventType: subscription.eventType, ...(subscription.expiresAt === undefined ? {} : { expiresAt: subscription.expiresAt }), failureCount: subscription.failureCount, filters: subscription.filters, id: subscription.id, ...(subscription.lastDeliveryAt === undefined ? {} : { lastDeliveryAt: subscription.lastDeliveryAt }), status: subscription.status, updatedAt: subscription.updatedAt, } } /** Public read shape plus the one-time signing secret (creation only). */ function toCreated(subscription: Webhooks.Subscription) { return { ...toPublic(subscription), destination: subscription.destination, secret: subscription.secret, } } /** Redacts bearer credentials from the destination returned by read endpoints. */ function redactDestination(destination: WebhookDestination.Destination) { if (destination.type === 'slack') return { ...destination, url: redactedSlackUrl } if (destination.type === 'betterstack') return { ...destination, token: redactedBetterstackToken } return destination } function createMutationError(c: Context, cause: unknown) { if (cause instanceof Webhooks.InvalidUrlError) return Response.error(c, { code: 'url_invalid', message: cause.message, status: 400 }) if (cause instanceof Webhooks.InvalidFilterError) return Response.error(c, { code: 'filters_invalid', details: cause.details, message: cause.message, status: 400, }) if (cause instanceof Webhooks.LimitExceededError) return Response.error(c, { code: 'limit_exceeded', message: cause.message, status: 403 }) return Response.upstream(c, cause) } function updateMutationError(c: Context, cause: unknown) { if (cause instanceof Webhooks.InvalidDestinationTransitionError) return Response.error(c, { code: 'destination_transition_invalid', message: cause.message, status: 400, }) if (cause instanceof Webhooks.InvalidUrlError) return Response.error(c, { code: 'url_invalid', message: cause.message, status: 400 }) if (cause instanceof Webhooks.InvalidFilterError) return Response.error(c, { code: 'filters_invalid', details: cause.details, message: cause.message, status: 400, }) return Response.upstream(c, cause) } function notFound(c: Context) { return Response.error(c, { code: 'webhook_not_found', message: 'Webhook not found', status: 404, }) } function notEnabled(c: Context) { return Response.error(c, { code: 'webhooks_not_enabled', message: 'Webhooks are not enabled for this Tempo API deployment.', status: 404, }) } function unauthorized(c: Context) { return Response.error(c, { code: 'unauthorized', message: 'Tempo could not determine which authenticated account owns this webhook.', status: 401, }) } // Pure per-block scanning (no RPC, no cursors, no pagination). // Pure over its inputs: the caller fetches the raw block (with transactions) // and its logs; enrichment happens downstream. Rows require a parsable block number. /** * Matches one fetched block against a subscription set, producing the rows * each event type delivers. Invalid stored filters fail closed into `invalid`. */ export function scanBlock( options: scanBlock.Options, ): scanBlock.Result { const { block, logs, subscriptions } = options const blockNumber = Value.hexToNumber(block['number']) const timestamp = Value.blockToIso(block) const header = toScanHeader(block) const viemLogs = logs.map(fromRpcLog) const matches: scanBlock.Match[] = [] const invalid: scanBlock.Invalid[] = [] for (const subscription of subscriptions) { const filters = (() => { try { return parseFilters(subscription.eventType, subscription.filters) } catch (error) { invalid.push({ error, subscription }) return undefined } })() if (filters === undefined) continue const matched = matches.length const push = (data: unknown, logIndex: number) => { if (blockNumber !== undefined) matches.push({ blockNumber, data, logIndex, subscription }) } // A throwing matcher invalidates only this subscription, rolling back any // rows it matched before the failure. try { switch (subscription.eventType) { case 'block:created': { const f = filters as z.output if (!matchesBlock(header, f)) break const data = toBlockEvent(header) if (data) push(data, 0) break } case 'log:emitted': { const f = filters as z.output const event = parseEvent(f.signature) if (f.signature && (!event || !/^[A-Za-z_]\w*$/.test(event.name))) { invalid.push({ error: new Webhooks.InvalidFilterError('log:emitted', [ { message: 'Unsupported event signature.', path: ['signature'] }, ]), subscription, }) break } // Validate arg names up front so a bad filter fails closed even // when the block has no decodable logs. const unknown = event && f.args ? Object.keys(f.args).find( (name) => !event.inputs.some((input) => input.name === name), ) : undefined if (unknown !== undefined) { invalid.push({ error: new Webhooks.InvalidFilterError('log:emitted', [ { message: `Unknown event argument: ${unknown}.`, path: ['args', unknown] }, ]), subscription, }) break } for (const log of viemLogs) { const decoded = event ? decodeScanLog(log, event) : log const logIndex = decoded.logIndex ?? undefined if (logIndex === undefined) continue if (!matchesLog(decoded, f, event)) continue const data = toLogEvent(decoded, timestamp, f.signature, event) if (data) push(data, logIndex) } break } case 'token:transfer': { if (timestamp === undefined) break const f = filters as WebhookTransfer.Filters for (const log of logs) { const data = WebhookTransfer.decode(log, timestamp) if (!data || !WebhookTransfer.matches(data, f)) continue push(data, data.logIndex) } break } case 'transaction:included': { const f = filters as z.output const includeCalls = f.includeCalls ?? true const transactions = Array.isArray(block['transactions']) ? (block['transactions'] as unknown[]) : [] const rawTimestamp = asString(block['timestamp']) for (let offset = 0; offset < transactions.length; offset++) { const transaction = transactions[offset] if (!transaction || typeof transaction !== 'object') continue const tx = transaction as Record const transactionIndex = Value.hexToNumber(tx['transactionIndex']) ?? offset if (!matchesTx(tx, f, includeCalls, rawTimestamp, blockNumber ?? 0)) continue const parsed = Transactions.schema.Rpc.Transaction.safeParse(tx) if (!parsed.success) continue const humanized = Transactions.humanizeTransaction(parsed.data) // A transaction's own timestamp is seconds-only; the enclosing // block carries milliseconds. push( timestamp === undefined ? humanized : { ...humanized, timestamp }, transactionIndex, ) } break } } } catch (error) { matches.length = matched invalid.push({ error, subscription }) } } return { invalid, matches } } /** Normalizes a raw RPC header to the viem-shaped fields the block matchers read. */ function toScanHeader(block: Record): RpcBlock { const quantity = (value: unknown) => { try { return typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint' ? BigInt(value) : undefined } catch { return undefined } } return { ...block, gasLimit: quantity(block['gasLimit']), gasUsed: quantity(block['gasUsed']), number: quantity(block['number']), timestamp: quantity(block['timestamp']), } as RpcBlock } export declare namespace scanBlock { /** The subscription fields matching consumes; extra fields flow through. */ type Subscription = { /** Event type selecting the matcher and payload shape. */ eventType: EventType /** Stored filter predicates, validated fail-closed per event type. */ filters: Record } /** One fetched block plus the subscriptions to match it against. */ type Options = { /** Raw `eth_getBlockByNumber(_, true)` result: header plus raw transactions. */ block: Record /** Raw `eth_getLogs` entries for exactly this block, in log order. */ logs: readonly RpcLog[] /** Active subscriptions for the chain; any mix of event types. */ subscriptions: readonly subscription[] } /** One deliverable row for one subscription. */ type Match = { /** Block the row was produced from. */ blockNumber: number /** Payload the matching read endpoint returns (pre-enrichment). */ data: unknown /** Row position within the block (log or transaction index; 0 for blocks). */ logIndex: number /** The matched subscription, passed through verbatim. */ subscription: subscription } /** A subscription whose stored filters failed validation. */ type Invalid = { /** The validation failure. */ error: unknown /** The subscription with invalid filters. */ subscription: subscription } /** Matches plus fail-closed filter rejections. */ type Result = { /** Subscriptions skipped because their stored filters failed validation. */ invalid: readonly Invalid[] /** Deliverable rows in subscription order, block order within each type. */ matches: readonly Match[] } }