From ea550c3ec69669706c7450da5cda49ffc7ba08cd Mon Sep 17 00:00:00 2001
From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com>
Date: Tue, 19 May 2026 12:00:12 -0700
Subject: [PATCH] feat: add tempo authorize support
---
.changeset/tempo-authorize.md | 5 +
.changeset/tip-1034-precompile-hash.md | 34 +
examples/README.md | 1 +
examples/authorize/README.md | 33 +
examples/authorize/index.html | 286 ++
examples/authorize/package.json | 20 +
examples/authorize/src/client.ts | 250 ++
examples/authorize/src/server.ts | 213 ++
examples/authorize/tsconfig.json | 20 +
examples/authorize/vite.config.ts | 35 +
pnpm-lock.yaml | 51 +-
src/Method.ts | 15 +
src/server/Mppx.authorize.test.ts | 85 +
src/server/Mppx.ts | 33 +-
src/tempo/Methods.test.ts | 51 +
src/tempo/Methods.ts | 76 +
src/tempo/authorize/Receipt.ts | 23 +
src/tempo/authorize/Store.test.ts | 49 +
src/tempo/authorize/Store.ts | 65 +
src/tempo/authorize/Types.ts | 31 +
src/tempo/authorize/index.ts | 3 +
src/tempo/client/Authorize.ts | 124 +
src/tempo/client/Methods.ts | 6 +
src/tempo/client/index.ts | 1 +
src/tempo/index.ts | 2 +
.../precompile/Chain.integration.test.ts | 321 +++
src/tempo/precompile/Chain.test.ts | 922 +++++++
src/tempo/precompile/Chain.ts | 647 +++++
src/tempo/precompile/Channel.test.ts | 110 +
src/tempo/precompile/Channel.ts | 76 +
src/tempo/precompile/Constants.ts | 2 +
src/tempo/precompile/Types.test.ts | 28 +
src/tempo/precompile/Types.ts | 83 +
src/tempo/precompile/Voucher.test.ts | 274 ++
src/tempo/precompile/Voucher.ts | 128 +
.../precompile/client/ChannelOps.test.ts | 115 +
src/tempo/precompile/client/ChannelOps.ts | 236 ++
src/tempo/precompile/client/Session.test.ts | 388 +++
src/tempo/precompile/client/Session.ts | 344 +++
src/tempo/precompile/client/index.ts | 2 +
src/tempo/precompile/escrow.abi.ts | 226 ++
src/tempo/precompile/index.ts | 11 +
.../server/Authorize.integration.test.ts | 316 +++
src/tempo/precompile/server/ChannelOps.ts | 119 +
.../server/Session.integration.test.ts | 486 ++++
src/tempo/precompile/server/Session.test.ts | 2356 +++++++++++++++++
src/tempo/precompile/server/Session.ts | 1014 +++++++
src/tempo/precompile/server/index.ts | 2 +
.../precompile/session/SessionManager.test.ts | 319 +++
.../precompile/session/SessionManager.ts | 856 ++++++
src/tempo/precompile/session/index.ts | 1 +
src/tempo/server/AtomicStore.test-d.ts | 20 +
src/tempo/server/Authorize.ts | 478 ++++
src/tempo/server/Methods.ts | 16 +
src/tempo/server/Session.test.ts | 52 +-
src/tempo/server/Session.ts | 2 +-
src/tempo/server/index.ts | 1 +
src/tempo/session/ChannelStore.test.ts | 81 +-
src/tempo/session/ChannelStore.ts | 37 +-
test/setup.ts | 203 +-
test/tempo/viem.ts | 31 +-
61 files changed, 11730 insertions(+), 85 deletions(-)
create mode 100644 .changeset/tempo-authorize.md
create mode 100644 .changeset/tip-1034-precompile-hash.md
create mode 100644 examples/authorize/README.md
create mode 100644 examples/authorize/index.html
create mode 100644 examples/authorize/package.json
create mode 100644 examples/authorize/src/client.ts
create mode 100644 examples/authorize/src/server.ts
create mode 100644 examples/authorize/tsconfig.json
create mode 100644 examples/authorize/vite.config.ts
create mode 100644 src/tempo/authorize/Receipt.ts
create mode 100644 src/tempo/authorize/Store.test.ts
create mode 100644 src/tempo/authorize/Store.ts
create mode 100644 src/tempo/authorize/Types.ts
create mode 100644 src/tempo/authorize/index.ts
create mode 100644 src/tempo/client/Authorize.ts
create mode 100644 src/tempo/precompile/Chain.integration.test.ts
create mode 100644 src/tempo/precompile/Chain.test.ts
create mode 100644 src/tempo/precompile/Chain.ts
create mode 100644 src/tempo/precompile/Channel.test.ts
create mode 100644 src/tempo/precompile/Channel.ts
create mode 100644 src/tempo/precompile/Constants.ts
create mode 100644 src/tempo/precompile/Types.test.ts
create mode 100644 src/tempo/precompile/Types.ts
create mode 100644 src/tempo/precompile/Voucher.test.ts
create mode 100644 src/tempo/precompile/Voucher.ts
create mode 100644 src/tempo/precompile/client/ChannelOps.test.ts
create mode 100644 src/tempo/precompile/client/ChannelOps.ts
create mode 100644 src/tempo/precompile/client/Session.test.ts
create mode 100644 src/tempo/precompile/client/Session.ts
create mode 100644 src/tempo/precompile/client/index.ts
create mode 100644 src/tempo/precompile/escrow.abi.ts
create mode 100644 src/tempo/precompile/index.ts
create mode 100644 src/tempo/precompile/server/Authorize.integration.test.ts
create mode 100644 src/tempo/precompile/server/ChannelOps.ts
create mode 100644 src/tempo/precompile/server/Session.integration.test.ts
create mode 100644 src/tempo/precompile/server/Session.test.ts
create mode 100644 src/tempo/precompile/server/Session.ts
create mode 100644 src/tempo/precompile/server/index.ts
create mode 100644 src/tempo/precompile/session/SessionManager.test.ts
create mode 100644 src/tempo/precompile/session/SessionManager.ts
create mode 100644 src/tempo/precompile/session/index.ts
create mode 100644 src/tempo/server/Authorize.ts
diff --git a/.changeset/tempo-authorize.md b/.changeset/tempo-authorize.md
new file mode 100644
index 00000000..6913524b
--- /dev/null
+++ b/.changeset/tempo-authorize.md
@@ -0,0 +1,5 @@
+---
+'mppx': patch
+---
+
+Added Tempo authorize method support for deferred TIP-20 captures.
diff --git a/.changeset/tip-1034-precompile-hash.md b/.changeset/tip-1034-precompile-hash.md
new file mode 100644
index 00000000..b5992b91
--- /dev/null
+++ b/.changeset/tip-1034-precompile-hash.md
@@ -0,0 +1,34 @@
+---
+'mppx': patch
+---
+
+Added opt-in Tempo [TIP-1034](https://github.com/tempoxyz/tempo/blob/main/tips/tip-1034.md) precompile support for payment-channel sessions.
+
+The default `tempo.session(...)` method continued to use the existing session backend. Applications opted into the new precompile-backed flow explicitly by using `tempo.precompile.session(...)` on the client and `tempo.precompile.Server.session(...)` on the server.
+
+```ts
+import { Mppx, tempo } from 'mppx/client'
+
+const client = Mppx.create({
+ methods: [tempo.precompile.session({ account, maxDeposit: '10' })],
+})
+```
+
+```ts
+import { Mppx, tempo } from 'mppx/server'
+
+const server = Mppx.create({
+ methods: [
+ tempo.precompile.Server.session({
+ amount: '1',
+ chainId,
+ currency,
+ recipient,
+ store,
+ unitType: 'request',
+ }),
+ ],
+})
+```
+
+This added channel ID, expiring nonce hash, voucher, ABI calldata, open/top-up validation, descriptor persistence, credential payload parsing, client credential builder, session manager, server verification, and server-driven fee-payer settle/close helpers for TIP20EscrowChannel precompile channels. It also changed precompile chain helpers to use `*OnChain` names, updated amount APIs to accept plain bigint values while validating uint96 bounds at encoding boundaries, updated precompile voucher signing inputs to match legacy session voucher signing, aligned precompile client session modules with the legacy client layout, and hardened precompile channel validation, atomic store updates, voucher-signing compatibility, finalized channel bookkeeping, and devnet precompile integration test setup.
diff --git a/examples/README.md b/examples/README.md
index 67b0c846..30020ba0 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -6,6 +6,7 @@ Standalone, runnable examples demonstrating the mppx HTTP 402 payment flow.
| Example | Description |
| --------------------------------------------- | ---------------------------------------------------- |
+| [authorize](./authorize/) | Deferred capture authorization playground |
| [charge](./charge/) | Payment-gated image generation API |
| [session/multi-fetch](./session/multi-fetch/) | Multiple paid requests over a single payment channel |
| [session/sse](./session/sse/) | Pay-per-token LLM streaming with SSE |
diff --git a/examples/authorize/README.md b/examples/authorize/README.md
new file mode 100644
index 00000000..6a15793e
--- /dev/null
+++ b/examples/authorize/README.md
@@ -0,0 +1,33 @@
+# Authorize Playground
+
+Deferred capture playground for Tempo authorizations.
+
+The app opens real TIP-1034 authorize channels through the HTTP 402 flow. The table then lets the server capture partial chunks, capture and close, or void the authorization.
+
+## Usage
+
+The default target is a local Tempo dev container:
+
+```bash
+pnpm dev
+```
+
+Override the RPC if your container exposes a different port:
+
+```bash
+MPPX_RPC_URL=http://localhost:7545/1 pnpm dev
+```
+
+To point at hosted networks instead:
+
+```bash
+MPPX_NETWORK=devnet pnpm dev
+MPPX_NETWORK=testnet pnpm dev
+```
+
+Optional environment:
+
+```bash
+MPPX_AUTHORIZE_CURRENCY=0x20c0000000000000000000000000000000000001
+MPPX_SERVER_PRIVATE_KEY=0x...
+```
diff --git a/examples/authorize/index.html b/examples/authorize/index.html
new file mode 100644
index 00000000..5b28e421
--- /dev/null
+++ b/examples/authorize/index.html
@@ -0,0 +1,286 @@
+
+
+
+
+
+ Tempo Authorize Playground
+
+
+
+
+
+
+
+
+
+
+
+
+ | Authorization |
+ Status |
+ Amount |
+ Captured |
+ Remaining |
+ Open tx |
+ Actions |
+
+
+
+
+ | No authorizations yet. |
+
+
+
+
+
+
+
+
+
diff --git a/examples/authorize/package.json b/examples/authorize/package.json
new file mode 100644
index 00000000..e1abf504
--- /dev/null
+++ b/examples/authorize/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "authorize",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "check:types": "tsgo -b",
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@remix-run/node-fetch-server": "^0.13.0",
+ "@types/node": "^25.6.0",
+ "@typescript/native-preview": "7.0.0-dev.20260323.1",
+ "mppx": "workspace:*",
+ "typescript": "~6.0.3",
+ "viem": "^2.50.4",
+ "vite": "^8.0.10"
+ }
+}
diff --git a/examples/authorize/src/client.ts b/examples/authorize/src/client.ts
new file mode 100644
index 00000000..00297ca6
--- /dev/null
+++ b/examples/authorize/src/client.ts
@@ -0,0 +1,250 @@
+import { Mppx, tempo } from 'mppx/client'
+import { createClient, formatUnits, http, parseUnits, type Address, type Hex } from 'viem'
+import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
+import { tempoDevnet, tempoLocalnet, tempoModerato } from 'viem/chains'
+import { Actions } from 'viem/tempo'
+
+type Config = {
+ chainId: number
+ currency: Address
+ decimals: number
+ network: 'devnet' | 'localnet' | 'testnet'
+ pricePerUnit: string
+ recipient: Address
+ rpcUrl: string
+ serverBalance: string
+}
+
+type Authorization = {
+ amount: string
+ capturedAmount: string
+ id: Hex
+ openTxHash: Hex
+ remainingAmount: string
+ status: 'authorized' | 'closed' | 'voided'
+}
+
+const statusEl = element('status')
+const unitsInput = element('units')
+const unitPriceEl = element('unit-price')
+const totalEl = element('authorize-total')
+const authorizeButton = element('authorize')
+const tbody = element('authorizations')
+const privateKeyStorageKey = 'mppx.authorize.privateKey'
+
+let config!: Config
+let mppx!: ReturnType
+let busy = false
+
+void boot().catch((error) => {
+ setStatus(errorMessage(error))
+})
+
+async function boot() {
+ setStatus('Loading config...')
+ config = await requestJson('/api/config')
+ const account = privateKeyToAccount(loadPrivateKey())
+ const client = createClient({
+ account,
+ chain:
+ config.network === 'devnet'
+ ? tempoDevnet
+ : config.network === 'localnet'
+ ? tempoLocalnet
+ : tempoModerato,
+ pollingInterval: 1_000,
+ transport: http(config.rpcUrl),
+ })
+ mppx = Mppx.create({
+ methods: [
+ tempo.authorize({
+ account,
+ getClient: () => client,
+ }),
+ ],
+ })
+
+ setStatus(`Funding ${short(account.address)} on ${config.network}...`)
+ await Actions.faucet.fundSync(client, { account, timeout: 30_000 })
+ const balance = await Actions.token.getBalance(client, { account, token: config.currency })
+ setStatus(
+ `Payer ${short(account.address)} | balance ${formatUnits(balance, config.decimals)} pathUSD`,
+ )
+
+ unitPriceEl.textContent = money(config.pricePerUnit)
+ unitsInput.addEventListener('input', updateTotal)
+ authorizeButton.addEventListener('click', () => void createAuthorization())
+ tbody.addEventListener('click', (event) => void handleTableClick(event))
+ updateTotal()
+ await refreshAuthorizations()
+}
+
+async function createAuthorization() {
+ if (busy) return
+ busy = true
+ authorizeButton.disabled = true
+ try {
+ const amount = authorizeAmount()
+ setStatus(`Authorizing ${money(amount)}...`)
+ await requestJson('/api/authorizations?amount=' + encodeURIComponent(amount), {
+ fetcher: mppx.fetch,
+ init: { method: 'POST' },
+ })
+ await refreshAuthorizations()
+ setStatus(`Authorized ${money(amount)}`)
+ } catch (error) {
+ setStatus(errorMessage(error))
+ } finally {
+ busy = false
+ authorizeButton.disabled = false
+ }
+}
+
+async function handleTableClick(event: Event) {
+ const target = event.target
+ if (!(target instanceof HTMLButtonElement)) return
+ const id = target.dataset.id as Hex | undefined
+ const action = target.dataset.action
+ if (!id || !action) return
+
+ const row = target.closest('tr')
+ const input = row?.querySelector('input[data-capture]')
+ const amount = input?.value
+
+ target.disabled = true
+ try {
+ if (action === 'void') {
+ setStatus(`Voiding ${short(id)}...`)
+ await requestJson(`/api/authorizations/${id}/void`, { init: { method: 'POST' } })
+ setStatus(`Voided ${short(id)}`)
+ } else {
+ if (!amount) throw new Error('Missing capture amount.')
+ setStatus(`${action === 'close' ? 'Capturing and closing' : 'Capturing'} ${money(amount)}...`)
+ await requestJson(`/api/authorizations/${id}/capture`, {
+ init: {
+ body: JSON.stringify({ amount, close: action === 'close' }),
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ },
+ })
+ setStatus(`${action === 'close' ? 'Closed' : 'Captured'} ${short(id)}`)
+ }
+ await refreshAuthorizations()
+ } catch (error) {
+ setStatus(errorMessage(error))
+ } finally {
+ target.disabled = false
+ }
+}
+
+async function refreshAuthorizations() {
+ const body = await requestJson<{ authorizations: Authorization[] }>('/api/authorizations')
+ renderAuthorizations(body.authorizations)
+}
+
+function renderAuthorizations(authorizations: Authorization[]) {
+ tbody.replaceChildren()
+ if (authorizations.length === 0) {
+ const tr = document.createElement('tr')
+ const td = document.createElement('td')
+ td.className = 'empty'
+ td.colSpan = 7
+ td.textContent = 'No authorizations yet.'
+ tr.append(td)
+ tbody.append(tr)
+ return
+ }
+
+ for (const authorization of authorizations) tbody.append(renderAuthorization(authorization))
+}
+
+function renderAuthorization(authorization: Authorization) {
+ const tr = document.createElement('tr')
+ const disabled = authorization.status !== 'authorized'
+ const suggestedCapture = minDecimal(config.pricePerUnit, authorization.remainingAmount)
+ tr.innerHTML = `
+ ${short(authorization.id)} |
+ ${authorization.status} |
+ ${money(authorization.amount)} |
+ ${money(authorization.capturedAmount)} |
+ ${money(authorization.remainingAmount)} |
+ ${short(authorization.openTxHash)} |
+
+
+
+
+
+
+
+ |
+ `
+ return tr
+}
+
+function updateTotal() {
+ totalEl.textContent = money(authorizeAmount())
+}
+
+function authorizeAmount() {
+ const units = Math.max(1, Math.floor(Number(unitsInput.value || '1')))
+ return formatUnits(
+ parseUnits(config.pricePerUnit, config.decimals) * BigInt(units),
+ config.decimals,
+ )
+}
+
+async function requestJson(
+ url: string,
+ options: { fetcher?: typeof fetch; init?: RequestInit } = {},
+): Promise {
+ const response = await (options.fetcher ?? fetch)(url, options.init)
+ const body = (await response.json().catch(() => null)) as value | { error?: string } | null
+ if (!response.ok) {
+ const message =
+ body && typeof body === 'object' && 'error' in body && body.error
+ ? body.error
+ : `Request failed: ${response.status}`
+ throw new Error(message)
+ }
+ return body as value
+}
+
+function loadPrivateKey() {
+ const existing = localStorage.getItem(privateKeyStorageKey)
+ if (existing) return existing as Hex
+ const privateKey = generatePrivateKey()
+ localStorage.setItem(privateKeyStorageKey, privateKey)
+ return privateKey
+}
+
+function minDecimal(a: string, b: string) {
+ const aRaw = parseUnits(a, config.decimals)
+ const bRaw = parseUnits(b, config.decimals)
+ return formatUnits(aRaw < bRaw ? aRaw : bRaw, config.decimals)
+}
+
+function money(value: string) {
+ return new Intl.NumberFormat('en-US', {
+ currency: 'USD',
+ maximumFractionDigits: 6,
+ style: 'currency',
+ }).format(Number(value))
+}
+
+function short(value: string) {
+ return `${value.slice(0, 8)}...${value.slice(-6)}`
+}
+
+function setStatus(message: string) {
+ statusEl.textContent = message
+}
+
+function errorMessage(error: unknown) {
+ return error instanceof Error ? error.message : String(error)
+}
+
+function element(id: string) {
+ const el = document.getElementById(id)
+ if (!el) throw new Error(`Missing #${id}`)
+ return el as type
+}
diff --git a/examples/authorize/src/server.ts b/examples/authorize/src/server.ts
new file mode 100644
index 00000000..75a0132f
--- /dev/null
+++ b/examples/authorize/src/server.ts
@@ -0,0 +1,213 @@
+import { Mppx, Store, tempo } from 'mppx/server'
+import { Authorize } from 'mppx/tempo'
+import { createClient, formatUnits, http, parseUnits, type Address, type Hex } from 'viem'
+import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
+import { tempoDevnet, tempoLocalnet, tempoModerato } from 'viem/chains'
+import { Actions } from 'viem/tempo'
+
+type NetworkName = 'devnet' | 'localnet' | 'testnet'
+type AuthorizationView = {
+ amount: string
+ capturedAmount: string
+ currency: Address
+ id: Hex
+ openTxHash: Hex
+ remainingAmount: string
+ status: 'authorized' | 'closed' | 'voided'
+}
+
+const decimals = 6
+const keyPrefix = 'example:authorize:'
+const network = resolveNetwork()
+const chain =
+ network === 'devnet' ? tempoDevnet : network === 'localnet' ? tempoLocalnet : tempoModerato
+const rpcUrl = process.env.MPPX_RPC_URL ?? chain.rpcUrls.default.http[0]
+const currency = resolveCurrency(network)
+const account = privateKeyToAccount(
+ (process.env.MPPX_SERVER_PRIVATE_KEY as Hex) ?? generatePrivateKey(),
+)
+const rawStore = Store.memory()
+const authorizationStore = Authorize.Store.fromStore(rawStore, { keyPrefix })
+const authorizationIds: Hex[] = []
+
+const client = createClient({
+ account,
+ chain,
+ pollingInterval: 1_000,
+ transport: http(rpcUrl),
+})
+
+const mppx = Mppx.create({
+ methods: [
+ tempo.authorize({
+ account,
+ chainId: chain.id,
+ currency,
+ decimals,
+ getClient: () => client,
+ keyPrefix,
+ recipient: account.address,
+ store: rawStore,
+ }),
+ ],
+ secretKey: process.env.MPP_SECRET_KEY ?? 'authorize-playground-secret',
+})
+
+await Actions.faucet.fundSync(client, { account, timeout: 30_000 }).catch((error) => {
+ console.warn('Failed to fund server account. Capture/void may fail until funded.', error)
+})
+
+export async function handler(request: Request): Promise {
+ const url = new URL(request.url)
+
+ if (url.pathname === '/api/health') return json({ status: 'ok' })
+ if (url.pathname === '/api/config') return json(await getConfig())
+ if (url.pathname === '/api/authorizations' && request.method === 'GET')
+ return json({ authorizations: await listAuthorizations() })
+ if (url.pathname === '/api/authorizations' && request.method === 'POST')
+ return createAuthorization(request, url)
+
+ const captureMatch = url.pathname.match(/^\/api\/authorizations\/(0x[0-9a-fA-F]+)\/capture$/)
+ if (captureMatch && request.method === 'POST')
+ return withJsonError(() => captureAuthorization(captureMatch[1] as Hex, request))
+
+ const voidMatch = url.pathname.match(/^\/api\/authorizations\/(0x[0-9a-fA-F]+)\/void$/)
+ if (voidMatch && request.method === 'POST')
+ return withJsonError(() => voidAuthorization(voidMatch[1] as Hex))
+
+ return null
+}
+
+async function createAuthorization(request: Request, url: URL) {
+ const amount = url.searchParams.get('amount')
+ if (!amount) return json({ error: 'missing amount' }, { status: 400 })
+
+ const result = await mppx.authorize({
+ amount,
+ description: `Authorize ${amount} pathUSD`,
+ expires: new Date(Date.now() + 20_000),
+ externalId: crypto.randomUUID(),
+ })(request)
+
+ if (result.status === 402) return result.challenge
+
+ const response = result.withReceipt()
+ const body = (await response.clone().json()) as { authorization: { id: Hex } }
+ rememberAuthorization(body.authorization.id)
+ const authorization = await getAuthorizationView(body.authorization.id)
+ return json({ authorization }, { headers: response.headers })
+}
+
+async function captureAuthorization(id: Hex, request: Request) {
+ const body = (await request.json()) as { amount?: string; close?: boolean }
+ if (!body.amount) return json({ error: 'missing amount' }, { status: 400 })
+
+ const receipt = await tempo.capture(rawStore, client, id, {
+ account,
+ amount: parseUnits(body.amount, decimals).toString(),
+ close: body.close,
+ keyPrefix,
+ })
+ rememberAuthorization(id)
+ return json({
+ authorization: await getAuthorizationView(id),
+ receipt: {
+ ...receipt,
+ capturedAmount: formatAmount(receipt.capturedAmount),
+ delta: formatAmount(receipt.delta),
+ },
+ })
+}
+
+async function voidAuthorization(id: Hex) {
+ const receipt = await tempo.voidAuthorization(rawStore, client, id, {
+ account,
+ keyPrefix,
+ })
+ rememberAuthorization(id)
+ return json({
+ authorization: await getAuthorizationView(id),
+ receipt: {
+ ...receipt,
+ releasedAmount: formatAmount(receipt.releasedAmount),
+ },
+ })
+}
+
+async function getConfig() {
+ const balance = await Actions.token.getBalance(client, { account, token: currency })
+ return {
+ chainId: chain.id,
+ currency,
+ decimals,
+ network,
+ pricePerUnit: '0.25',
+ recipient: account.address,
+ rpcUrl,
+ serverBalance: formatUnits(balance, decimals),
+ }
+}
+
+async function listAuthorizations() {
+ const authorizations = await Promise.all(authorizationIds.map((id) => getAuthorizationView(id)))
+ return authorizations.filter((authorization): authorization is AuthorizationView =>
+ Boolean(authorization),
+ )
+}
+
+async function getAuthorizationView(id: Hex): Promise {
+ const authorization = await authorizationStore.get(id)
+ if (!authorization) return null
+ const amount = BigInt(authorization.amount)
+ const capturedAmount = BigInt(authorization.capturedAmount)
+ return {
+ amount: formatUnits(amount, decimals),
+ capturedAmount: formatUnits(capturedAmount, decimals),
+ currency: authorization.channel.descriptor.token,
+ id: authorization.channel.id,
+ openTxHash: authorization.openTxHash,
+ remainingAmount: formatUnits(amount - capturedAmount, decimals),
+ status: authorization.status,
+ }
+}
+
+function rememberAuthorization(id: Hex) {
+ const normalized = id.toLowerCase() as Hex
+ if (!authorizationIds.some((existing) => existing.toLowerCase() === normalized))
+ authorizationIds.unshift(normalized)
+}
+
+function formatAmount(value: string | bigint) {
+ return formatUnits(BigInt(value), decimals)
+}
+
+function resolveNetwork(): NetworkName {
+ const value = process.env.MPPX_NETWORK
+ if (value === 'devnet' || value === 'localnet' || value === 'testnet') return value
+ return 'localnet'
+}
+
+function resolveCurrency(network: NetworkName): Address {
+ const configured = process.env.MPPX_AUTHORIZE_CURRENCY as Address | undefined
+ if (configured) return configured
+ if (network === 'testnet') return '0x20c0000000000000000000000000000000000000'
+ return '0x20c0000000000000000000000000000000000001'
+}
+
+async function withJsonError(fn: () => Promise) {
+ try {
+ return await fn()
+ } catch (error) {
+ return json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })
+ }
+}
+
+function json(data: unknown, init: ResponseInit = {}) {
+ return Response.json(data, {
+ ...init,
+ headers: {
+ 'Cache-Control': 'no-store',
+ ...Object.fromEntries(new Headers(init.headers)),
+ },
+ })
+}
diff --git a/examples/authorize/tsconfig.json b/examples/authorize/tsconfig.json
new file mode 100644
index 00000000..e459c1d9
--- /dev/null
+++ b/examples/authorize/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "lib": ["ESNext", "DOM"],
+ "types": ["node"],
+ "noEmit": true,
+ "paths": {
+ "mppx": ["../../src/index.ts"],
+ "mppx/client": ["../../src/client/index.ts"],
+ "mppx/server": ["../../src/server/index.ts"],
+ "mppx/tempo": ["../../src/tempo/index.ts"]
+ }
+ },
+ "include": ["src/**/*", "vite.config.ts"]
+}
diff --git a/examples/authorize/vite.config.ts b/examples/authorize/vite.config.ts
new file mode 100644
index 00000000..26d69bb9
--- /dev/null
+++ b/examples/authorize/vite.config.ts
@@ -0,0 +1,35 @@
+import { createRequest, sendResponse } from '@remix-run/node-fetch-server'
+import { defineConfig, type Plugin, type ViteDevServer } from 'vite'
+
+const startupLogDelayMs = 100
+
+export default defineConfig({
+ plugins: [apiPlugin()],
+})
+
+function apiPlugin(): Plugin {
+ return {
+ name: 'api',
+ async configureServer(server) {
+ const { handler } = await import('./src/server.ts')
+ // oxlint-disable-next-line no-async-endpoint-handlers
+ server.middlewares.use(async (req, res, next) => {
+ const request = createRequest(req, res)
+ const response = await handler(request)
+ if (response) await sendResponse(res, response)
+ else next()
+ })
+ server.httpServer?.once('listening', () => logStartup(server))
+ },
+ }
+}
+
+function logStartup(server: ViteDevServer) {
+ const address = server.httpServer?.address()
+ const host =
+ typeof address === 'object' && address ? `localhost:${address.port}` : 'localhost:5173'
+ setTimeout(() => {
+ console.log(`\n Open http://${host}/`)
+ console.log(` POST authorizations with: npx mppx http://${host}/api/authorizations?amount=1\n`)
+ }, startupLogDelayMs)
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 558b7d2b..9ade5648 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -133,6 +133,30 @@ importers:
specifier: ^0.0.25
version: 0.0.25(@typescript/native-preview@7.0.0-dev.20260323.1)(typescript@5.9.3)
+ examples/authorize:
+ dependencies:
+ '@remix-run/node-fetch-server':
+ specifier: ^0.13.0
+ version: 0.13.1
+ '@types/node':
+ specifier: ^25.6.0
+ version: 25.9.0
+ '@typescript/native-preview':
+ specifier: 7.0.0-dev.20260323.1
+ version: 7.0.0-dev.20260323.1
+ mppx:
+ specifier: workspace:*
+ version: link:../..
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ viem:
+ specifier: ^2.50.4
+ version: 2.50.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)
+ vite:
+ specifier: ^8.0.10
+ version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.8.4)
+
examples/charge:
dependencies:
'@remix-run/node-fetch-server':
@@ -1846,9 +1870,6 @@ packages:
'@types/node@25.6.2':
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
- '@types/node@25.8.0':
- resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==}
-
'@types/node@25.9.0':
resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==}
@@ -5507,7 +5528,7 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
- '@types/node': 25.8.0
+ '@types/node': 25.9.0
'@types/chai@5.2.3':
dependencies:
@@ -5516,7 +5537,7 @@ snapshots:
'@types/connect@3.4.38':
dependencies:
- '@types/node': 25.8.0
+ '@types/node': 25.9.0
'@types/debug@4.1.13':
dependencies:
@@ -5526,13 +5547,13 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
- '@types/node': 25.8.0
+ '@types/node': 25.9.0
'@types/ssh2': 1.15.5
'@types/dockerode@4.0.1':
dependencies:
'@types/docker-modem': 3.0.6
- '@types/node': 25.8.0
+ '@types/node': 25.9.0
'@types/ssh2': 1.15.5
'@types/esrecurse@4.3.1': {}
@@ -5541,7 +5562,7 @@ snapshots:
'@types/express-serve-static-core@5.1.1':
dependencies:
- '@types/node': 25.8.0
+ '@types/node': 25.9.0
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -5572,10 +5593,6 @@ snapshots:
dependencies:
undici-types: 7.19.2
- '@types/node@25.8.0':
- dependencies:
- undici-types: 7.24.6
-
'@types/node@25.9.0':
dependencies:
undici-types: 7.24.6
@@ -5594,20 +5611,20 @@ snapshots:
'@types/send@1.2.1':
dependencies:
- '@types/node': 25.8.0
+ '@types/node': 25.9.0
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
- '@types/node': 25.8.0
+ '@types/node': 25.9.0
'@types/ssh2-streams@0.1.13':
dependencies:
- '@types/node': 25.8.0
+ '@types/node': 25.9.0
'@types/ssh2@0.5.52':
dependencies:
- '@types/node': 25.8.0
+ '@types/node': 25.9.0
'@types/ssh2-streams': 0.1.13
'@types/ssh2@1.15.5':
@@ -7654,7 +7671,7 @@ snapshots:
stripe@17.7.0:
dependencies:
- '@types/node': 25.8.0
+ '@types/node': 25.9.0
qs: 6.14.2
strtok3@10.3.5:
diff --git a/src/Method.ts b/src/Method.ts
index 958f3622..3a2291e7 100755
--- a/src/Method.ts
+++ b/src/Method.ts
@@ -198,6 +198,21 @@ export type StableBindingFn = (
request: z.output,
) => Record
+/** Verification function for a single method. */
+export type VerifyResult =
+ | Receipt.Receipt
+ | {
+ /** Response for verified management actions that do not consume value. */
+ response: globalThis.Response
+ }
+
+/** Returns true when a verification result is a response without a receipt. */
+export function isVerifyResponse(
+ result: unknown,
+): result is Extract {
+ return typeof result === 'object' && result !== null && 'response' in result
+}
+
/** Verification function for a single method. */
export type VerifyFn = (
parameters: VerifyContext,
diff --git a/src/server/Mppx.authorize.test.ts b/src/server/Mppx.authorize.test.ts
index d238e3ee..be712d9f 100644
--- a/src/server/Mppx.authorize.test.ts
+++ b/src/server/Mppx.authorize.test.ts
@@ -93,6 +93,91 @@ describe('authorize hook', () => {
server.close()
})
+ test('verified management responses can omit Payment-Receipt', async () => {
+ const method = Method.toServer(
+ Method.from({
+ name: 'mock',
+ intent: 'authorize',
+ schema: {
+ credential: { payload: z.object({ token: z.string() }) },
+ request: z.object({ amount: z.string() }),
+ },
+ }),
+ {
+ async verify() {
+ return {
+ response: Response.json({ authorization: { id: 'auth-1' } }),
+ } as never
+ },
+ },
+ )
+
+ const handler = Mppx.create({ methods: [method], realm, secretKey })
+ const first = await handler.authorize({ amount: '1' })(
+ new Request('https://example.com/resource'),
+ )
+
+ expect(first.status).toBe(402)
+ if (first.status !== 402) throw new Error('expected challenge')
+
+ const credential = Credential.from({
+ challenge: Challenge.fromResponse(first.challenge),
+ payload: { token: 'ok' },
+ })
+ const second = await handler.authorize({ amount: '1' })(
+ new Request('https://example.com/resource', {
+ headers: { Authorization: Credential.serialize(credential) },
+ }),
+ )
+
+ expect(second.status).toBe(200)
+ if (second.status !== 200) throw new Error('expected authorize success')
+
+ const response = second.withReceipt()
+ expect(response.headers.get('Payment-Receipt')).toBeNull()
+ expect(await response.json()).toEqual({ authorization: { id: 'auth-1' } })
+ })
+
+ test('verified management responses ignore protected handler responses', async () => {
+ const method = Method.toServer(
+ Method.from({
+ name: 'mock',
+ intent: 'authorize',
+ schema: {
+ credential: { payload: z.object({ token: z.string() }) },
+ request: z.object({ amount: z.string() }),
+ },
+ }),
+ {
+ async verify() {
+ return {
+ response: Response.json({ authorization: { id: 'auth-1' } }),
+ } as never
+ },
+ },
+ )
+
+ const handler = Mppx.create({ methods: [method], realm, secretKey })
+ const first = await handler.authorize({ amount: '1' })(
+ new Request('https://example.com/resource'),
+ )
+ if (first.status !== 402) throw new Error('expected challenge')
+
+ const credential = Credential.from({
+ challenge: Challenge.fromResponse(first.challenge),
+ payload: { token: 'ok' },
+ })
+ const second = await handler.authorize({ amount: '1' })(
+ new Request('https://example.com/resource', {
+ headers: { Authorization: Credential.serialize(credential) },
+ }),
+ )
+ if (second.status !== 200) throw new Error('expected authorize success')
+
+ const response = second.withReceipt(new Response('protected content'))
+ expect(await response.json()).toEqual({ authorization: { id: 'auth-1' } })
+ })
+
test('compose evaluates authorize hooks sequentially on no-credential requests', async () => {
const calls: string[] = []
const createMethod = (
diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts
index 061934fc..2596fc09 100644
--- a/src/server/Mppx.ts
+++ b/src/server/Mppx.ts
@@ -8,7 +8,7 @@ import * as Expires from '../Expires.js'
import * as AcceptPayment from '../internal/AcceptPayment.js'
import * as Env from '../internal/env.js'
import type { MaybePromise } from '../internal/types.js'
-import type * as Method from '../Method.js'
+import * as Method from '../Method.js'
import * as PaymentRequest from '../PaymentRequest.js'
import type * as Receipt from '../Receipt.js'
import * as z from '../zod.js'
@@ -642,20 +642,12 @@ export function create<
} as Method.VerifiedChallengeEnvelope)
: undefined
- let receipt: Receipt.Receipt
- try {
- receipt = await mi.verify({ credential: parsedCredential, envelope, request } as never)
- } catch (e) {
- const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
- await emitStandalonePaymentFailed({
- challenge: credential.challenge,
- credential: parsedCredential,
- error,
- request: parsedRequest,
- submittedChallenge: credential.challenge,
+ const result = await mi.verify({ credential, envelope, request } as never)
+ if (Method.isVerifyResponse(result))
+ throw new Errors.VerificationFailedError({
+ reason: 'verification returned a response without a receipt',
})
- throw e
- }
+ const receipt = result as Receipt.Receipt
await serverEvents.emit(
'payment.success',
@@ -961,6 +953,13 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
}
}
+ const successResponse = (response: globalThis.Response): MethodFn.Response => ({
+ status: 200,
+ withReceipt() {
+ return response as wrapped
+ },
+ })
+
// No credential provided—issue challenge
if (!credential) {
if (authorize && input instanceof globalThis.Request) {
@@ -1160,9 +1159,9 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
// User-provided verification (e.g., check signature, submit tx, verify payment).
// If verification fails, re-issue the challenge so the client can retry.
- let receiptData: Receipt.Receipt
+ let verifyResult: unknown
try {
- receiptData = await verify({ credential: parsedCredential, envelope, request } as never)
+ verifyResult = await verify({ credential: parsedCredential, envelope, request } as never)
} catch (e) {
if (!(e instanceof Errors.PaymentError))
console.error('mppx: internal verification error', e)
@@ -1183,6 +1182,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
})
return { challenge: response, status: 402 }
}
+ if (Method.isVerifyResponse(verifyResult)) return successResponse(verifyResult.response)
+ const receiptData = verifyResult as Receipt.Receipt
// If the method's `respond` hook returns a Response, it means this
// request is a management action (e.g. channel open, voucher POST)
diff --git a/src/tempo/Methods.test.ts b/src/tempo/Methods.test.ts
index 3594a37f..c09d2377 100644
--- a/src/tempo/Methods.test.ts
+++ b/src/tempo/Methods.test.ts
@@ -250,6 +250,57 @@ describe('session', () => {
})
})
+describe('authorize', () => {
+ test('has correct name and intent', () => {
+ expect(Methods.authorize.intent).toBe('authorize')
+ expect(Methods.authorize.name).toBe('tempo')
+ })
+
+ test('schema: validates request and encodes amount in base units', () => {
+ const request = Methods.authorize.schema.request.parse({
+ amount: '10',
+ authorizedSigner: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ chainId: 4217,
+ currency: '0x20c0000000000000000000000000000000000001',
+ decimals: 6,
+ feePayer: true,
+ operator: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
+ })
+
+ expect(request.amount).toBe('10000000')
+ expect(request.methodDetails).toEqual({
+ authorizedSigner: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ chainId: 4217,
+ feePayer: true,
+ operator: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ })
+ expect('authorizationExpires' in request).toBe(false)
+ })
+
+ test('schema: rejects amounts greater than uint96', () => {
+ const result = Methods.authorize.schema.request.safeParse({
+ amount: (1n << 96n).toString(),
+ authorizedSigner: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ currency: '0x20c0000000000000000000000000000000000001',
+ decimals: 0,
+ operator: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
+ })
+
+ expect(result.success).toBe(false)
+ })
+
+ test('schema: validates transaction payload', () => {
+ const result = Methods.authorize.schema.credential.payload.safeParse({
+ channelId: '0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890',
+ transaction: '0x76f90100000000000000000000000000000000000000000000000000000000000000000000',
+ })
+
+ expect(result.success).toBe(true)
+ })
+})
+
describe('subscription', () => {
test('has correct name and intent', () => {
expect(Methods.subscription.intent).toBe('subscription')
diff --git a/src/tempo/Methods.ts b/src/tempo/Methods.ts
index 7f7447ce..16059212 100644
--- a/src/tempo/Methods.ts
+++ b/src/tempo/Methods.ts
@@ -3,6 +3,7 @@ import { parseUnits } from 'viem'
import * as Method from '../Method.js'
import * as z from '../zod.js'
+import type * as PrecompileChannel from './precompile/Channel.js'
import type { SubscriptionPeriodUnit } from './subscription/Types.js'
export const chargeModes = ['push', 'pull'] as const
@@ -55,6 +56,7 @@ const subscriptionPeriodUnits = [
'week',
] as const satisfies readonly SubscriptionPeriodUnit[]
const subscriptionPeriodUnit = z.enum(subscriptionPeriodUnits)
+const uint96Max = (1n << 96n) - 1n
const uint64String = z
.pipe(
@@ -83,6 +85,13 @@ function positiveParsedAmount(message: string) {
}, message)
}
+function parsedAmountFitsUint96(message: string) {
+ return z.refine((value) => {
+ const { amount, decimals } = value as { amount: string; decimals: number }
+ return parseUnits(amount, decimals) <= uint96Max
+ }, message)
+}
+
function subscriptionPeriodFitsUint64(value: unknown) {
const { periodCount, periodUnit } = value as {
periodCount: string
@@ -196,6 +205,7 @@ export const session = Method.from({
authorizedSigner: z.optional(z.string()),
channelId: z.hash(),
cumulativeAmount: z.amount(),
+ descriptor: z.optional(z.custom()),
signature: z.signature(),
transaction: z.signature(),
type: z.literal('transaction'),
@@ -204,6 +214,7 @@ export const session = Method.from({
action: z.literal('topUp'),
additionalDeposit: z.amount(),
channelId: z.hash(),
+ descriptor: z.optional(z.custom()),
transaction: z.signature(),
type: z.literal('transaction'),
}),
@@ -211,12 +222,14 @@ export const session = Method.from({
action: z.literal('voucher'),
channelId: z.hash(),
cumulativeAmount: z.amount(),
+ descriptor: z.optional(z.custom()),
signature: z.signature(),
}),
z.object({
action: z.literal('close'),
channelId: z.hash(),
cumulativeAmount: z.amount(),
+ descriptor: z.optional(z.custom()),
signature: z.signature(),
}),
]),
@@ -281,6 +294,69 @@ export const session = Method.from({
},
})
+/**
+ * Tempo authorize intent for deferred capture over TIP-1034 channels.
+ *
+ * Opens a dedicated TIP-1034 channel funded up to `amount`; later captures
+ * settle cumulative vouchers against that channel.
+ */
+export const authorize = Method.from({
+ name: 'tempo',
+ intent: 'authorize',
+ schema: {
+ credential: {
+ payload: z.object({
+ channelId: z.hash(),
+ transaction: z.signature(),
+ }),
+ },
+ request: z.pipe(
+ z
+ .object({
+ amount: z.amount(),
+ authorizedSigner: normalizedAddress,
+ chainId: z.optional(z.number()),
+ currency: normalizedAddress,
+ decimals: z.number(),
+ description: z.optional(z.string()),
+ escrowContract: z.optional(normalizedAddress),
+ externalId: z.optional(z.string()),
+ feePayer: z.optional(
+ z.pipe(
+ z.union([z.boolean(), z.custom()]),
+ z.transform((v): boolean => (typeof v === 'object' ? true : v)),
+ ),
+ ),
+ operator: normalizedAddress,
+ recipient: normalizedAddress,
+ })
+ .check(parsedAmountFitsUint96('Authorize amount exceeds uint96')),
+ z.transform(
+ ({
+ amount,
+ authorizedSigner,
+ chainId,
+ decimals,
+ escrowContract,
+ feePayer,
+ operator,
+ ...rest
+ }) => ({
+ ...rest,
+ amount: parseUnits(amount, decimals).toString(),
+ methodDetails: {
+ authorizedSigner,
+ ...(chainId !== undefined && { chainId }),
+ ...(escrowContract !== undefined && { escrowContract }),
+ ...(feePayer !== undefined && { feePayer }),
+ operator,
+ },
+ }),
+ ),
+ ),
+ },
+})
+
/**
* Tempo subscription intent for recurring TIP-20 token transfers.
*
diff --git a/src/tempo/authorize/Receipt.ts b/src/tempo/authorize/Receipt.ts
new file mode 100644
index 00000000..bca378bb
--- /dev/null
+++ b/src/tempo/authorize/Receipt.ts
@@ -0,0 +1,23 @@
+import type { Hex } from 'viem'
+
+import type { Receipt } from './Types.js'
+
+/** Creates a Tempo authorize capture receipt. */
+export function create(parameters: {
+ authorizationId: Hex
+ capturedAmount: bigint
+ delta: bigint
+ reference: Hex
+ timestamp?: Date | undefined
+}): Receipt {
+ return {
+ authorizationId: parameters.authorizationId,
+ capturedAmount: parameters.capturedAmount.toString(),
+ delta: parameters.delta.toString(),
+ intent: 'authorize',
+ method: 'tempo',
+ reference: parameters.reference,
+ status: 'success',
+ timestamp: (parameters.timestamp ?? new Date()).toISOString(),
+ }
+}
diff --git a/src/tempo/authorize/Store.test.ts b/src/tempo/authorize/Store.test.ts
new file mode 100644
index 00000000..f2afefd7
--- /dev/null
+++ b/src/tempo/authorize/Store.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, test } from 'vp/test'
+
+import * as Store from '../../Store.js'
+import * as AuthorizeStore from './Store.js'
+import type { Authorization } from './Types.js'
+
+const channelId = '0x1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890'
+const authorization = {
+ amount: '1000',
+ capturedAmount: '0',
+ challengeId: 'challenge-1',
+ channel: {
+ chainId: 1,
+ descriptor: {
+ authorizedSigner: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ expiringNonceHash: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ operator: '0xcccccccccccccccccccccccccccccccccccccccc',
+ payee: '0xdddddddddddddddddddddddddddddddddddddddd',
+ payer: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
+ salt: '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
+ token: '0x20c0000000000000000000000000000000000001',
+ },
+ escrow: '0x0000000000000000000000000000000000000101',
+ id: channelId,
+ },
+ openTxHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
+ status: 'authorized',
+} satisfies Authorization
+
+describe('authorize store', () => {
+ test('uses default key prefix', async () => {
+ const raw = Store.memory()
+ const store = AuthorizeStore.fromStore(raw)
+
+ expect(store.keyPrefix).toBe(AuthorizeStore.defaultKeyPrefix)
+ expect(await store.create(authorization)).toBe('created')
+ expect(await raw.get(`${AuthorizeStore.defaultKeyPrefix}${channelId}`)).toEqual(authorization)
+ })
+
+ test('supports custom key prefix', async () => {
+ const raw = Store.memory()
+ const store = AuthorizeStore.fromStore(raw, { keyPrefix: 'tenant-a:authorize:' })
+
+ expect(store.keyPrefix).toBe('tenant-a:authorize:')
+ expect(await store.create(authorization)).toBe('created')
+ expect(await AuthorizeStore.fromStore(raw).get(channelId)).toBeNull()
+ expect(await store.get(channelId)).toEqual(authorization)
+ })
+})
diff --git a/src/tempo/authorize/Store.ts b/src/tempo/authorize/Store.ts
new file mode 100644
index 00000000..c690cc84
--- /dev/null
+++ b/src/tempo/authorize/Store.ts
@@ -0,0 +1,65 @@
+import type { Hex } from 'viem'
+
+import type * as Store from '../../Store.js'
+import type { Authorization, Receipt } from './Types.js'
+
+export const defaultKeyPrefix = 'tempo:authorize:'
+
+export type StoreItemMap = Record<
+ `${keyPrefix}${string}`,
+ Authorization
+>
+
+/** Store wrapper for Tempo authorize records. */
+export type AuthorizationStore = {
+ readonly keyPrefix: string
+ create(authorization: Authorization): Promise<'created' | 'exists'>
+ get(id: Hex): Promise
+ update(
+ id: Hex,
+ fn: (current: Authorization | null) => Store.Change,
+ ): Promise
+}
+
+/** Wraps a generic atomic store for Tempo authorize state. */
+export function fromStore(
+ store: Store.AtomicStore,
+ options: fromStore.Options = {},
+): AuthorizationStore {
+ const keyPrefix = options.keyPrefix ?? defaultKeyPrefix
+ return {
+ keyPrefix,
+ async create(authorization) {
+ return store.update(toKey(authorization.channel.id, keyPrefix), (current) => {
+ if (current) return { op: 'noop', result: 'exists' as const }
+ return { op: 'set', value: authorization, result: 'created' as const }
+ })
+ },
+ async get(id) {
+ return (await store.get(toKey(id, keyPrefix))) as Authorization | null
+ },
+ async update(id, fn) {
+ return store.update(toKey(id, keyPrefix), (current) =>
+ fn((current as Authorization | null) ?? null),
+ )
+ },
+ }
+}
+
+export declare namespace fromStore {
+ type Options = {
+ keyPrefix?: string | undefined
+ }
+}
+
+export function getCaptureReceipt(
+ authorization: Authorization,
+ idempotencyKey: string | undefined,
+): Receipt | undefined {
+ if (!idempotencyKey) return undefined
+ return authorization.captureReceipts?.[idempotencyKey]
+}
+
+function toKey(id: Hex, keyPrefix: string) {
+ return `${keyPrefix}${id.toLowerCase()}` as const
+}
diff --git a/src/tempo/authorize/Types.ts b/src/tempo/authorize/Types.ts
new file mode 100644
index 00000000..91e62f86
--- /dev/null
+++ b/src/tempo/authorize/Types.ts
@@ -0,0 +1,31 @@
+import type { Address, Hex } from 'viem'
+
+import type * as Channel from '../precompile/Channel.js'
+
+/** Durable state for a Tempo authorize channel. */
+export type Authorization = {
+ amount: string
+ capturedAmount: string
+ captureReceipts?: Record | undefined
+ challengeId: string
+ channel: {
+ chainId: number
+ descriptor: Channel.ChannelDescriptor
+ escrow: Address
+ id: Hex
+ }
+ openTxHash: Hex
+ status: 'authorized' | 'closed' | 'voided'
+}
+
+/** Payment receipt emitted when a Tempo authorization captures value. */
+export type Receipt = {
+ authorizationId: Hex
+ capturedAmount: string
+ delta: string
+ intent: 'authorize'
+ method: 'tempo'
+ reference: Hex
+ status: 'success'
+ timestamp: string
+}
diff --git a/src/tempo/authorize/index.ts b/src/tempo/authorize/index.ts
new file mode 100644
index 00000000..20ad329b
--- /dev/null
+++ b/src/tempo/authorize/index.ts
@@ -0,0 +1,3 @@
+export * as Receipt from './Receipt.js'
+export * as Store from './Store.js'
+export type { Authorization, Receipt as AuthorizeReceipt } from './Types.js'
diff --git a/src/tempo/client/Authorize.ts b/src/tempo/client/Authorize.ts
new file mode 100644
index 00000000..349ec942
--- /dev/null
+++ b/src/tempo/client/Authorize.ts
@@ -0,0 +1,124 @@
+import { Hex } from 'ox'
+import { encodeFunctionData, type Account, type Address, type Client as ViemClient } from 'viem'
+import { prepareTransactionRequest, signTransaction } from 'viem/actions'
+import { tempo as tempo_chain } from 'viem/chains'
+
+import * as Credential from '../../Credential.js'
+import type { MaybePromise } from '../../internal/types.js'
+import * as Method from '../../Method.js'
+import * as AccountResolver from '../../viem/Account.js'
+import * as Client from '../../viem/Client.js'
+import * as z from '../../zod.js'
+import * as defaults from '../internal/defaults.js'
+import * as Methods from '../Methods.js'
+import * as Channel from '../precompile/Channel.js'
+import { tip20ChannelEscrow } from '../precompile/Constants.js'
+import { escrowAbi } from '../precompile/escrow.abi.js'
+
+type AuthorizeRequest = ReturnType
+const expiringNonceValiditySeconds = 20
+
+/** Context accepted by the Tempo authorize client method. */
+export const authorizeContextSchema = z.object({
+ account: z.optional(z.custom()),
+})
+
+/** Creates a Tempo authorize client method. */
+export function authorize(parameters: authorize.Parameters = {}) {
+ const getClient = Client.getResolver({
+ chain: tempo_chain,
+ getClient: parameters.getClient,
+ rpcUrl: defaults.rpcUrl,
+ })
+ const getAccount = AccountResolver.getResolver({ account: parameters.account })
+
+ return Method.toClient(Methods.authorize, {
+ context: authorizeContextSchema,
+
+ async createCredential({ challenge, context }) {
+ await parameters.validateRequest?.(challenge.request)
+
+ const chainId = challenge.request.methodDetails.chainId ?? defaults.chainId.mainnet
+ const client = await getClient({ chainId })
+ const account = getAccount(client, context)
+ const payload = await createAuthorizePayload(client, account, challenge.request, {
+ chainId,
+ expires: challenge.expires,
+ })
+
+ return Credential.serialize({
+ challenge,
+ payload,
+ source: `did:pkh:eip155:${chainId}:${account.address}`,
+ })
+ },
+ })
+}
+
+async function createAuthorizePayload(
+ client: ViemClient,
+ account: Account,
+ request: AuthorizeRequest,
+ options: { chainId: number; expires?: string | undefined },
+) {
+ const methodDetails = request.methodDetails
+ const escrow = (methodDetails.escrowContract ?? tip20ChannelEscrow) as Address
+ const salt = Hex.random(32)
+ const prepared = await prepareTransactionRequest(client, {
+ account,
+ calls: [{ to: escrow, data: encodeOpenCall(request, salt) }],
+ ...(methodDetails.feePayer ? { feePayer: true } : {}),
+ feeToken: request.currency as Address,
+ nonceKey: 'expiring',
+ validBefore: toValidBefore(options.expires),
+ } as never)
+ const expiringNonceHash = Channel.computeExpiringNonceHash(
+ prepared as Channel.ExpiringNonceTransaction,
+ { sender: account.address },
+ )
+ const channelId = Channel.computeId({
+ authorizedSigner: methodDetails.authorizedSigner as Address,
+ chainId: options.chainId,
+ escrow,
+ expiringNonceHash,
+ operator: methodDetails.operator as Address,
+ payee: request.recipient as Address,
+ payer: account.address,
+ salt,
+ token: request.currency as Address,
+ })
+ return {
+ channelId,
+ transaction: (await signTransaction(client, prepared as never)) as Hex.Hex,
+ }
+}
+
+function encodeOpenCall(request: AuthorizeRequest, salt: Hex.Hex) {
+ const methodDetails = request.methodDetails
+ return encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'open',
+ args: [
+ request.recipient as Address,
+ methodDetails.operator as Address,
+ request.currency as Address,
+ BigInt(request.amount),
+ salt,
+ methodDetails.authorizedSigner as Address,
+ ],
+ })
+}
+
+function toValidBefore(expires: string | undefined) {
+ if (!expires) throw new Error('tempo.authorize() requires a challenge expiry.')
+ const challengeExpires = Math.floor(new Date(expires).getTime() / 1_000)
+ const nonceExpires = Math.floor(Date.now() / 1_000) + expiringNonceValiditySeconds
+ return Math.min(challengeExpires, nonceExpires)
+}
+
+export declare namespace authorize {
+ type Parameters = AccountResolver.getResolver.Parameters &
+ Client.getResolver.Parameters & {
+ validateRequest?: ((request: AuthorizeRequest) => MaybePromise) | undefined
+ }
+}
diff --git a/src/tempo/client/Methods.ts b/src/tempo/client/Methods.ts
index a46908d8..38cc9aa1 100644
--- a/src/tempo/client/Methods.ts
+++ b/src/tempo/client/Methods.ts
@@ -1,3 +1,5 @@
+import * as Precompile_ from '../precompile/index.js'
+import { authorize as authorize_ } from './Authorize.js'
import { charge as charge_ } from './Charge.js'
import { session as sessionIntent_ } from './Session.js'
import { sessionManager as session_ } from './SessionManager.js'
@@ -22,10 +24,14 @@ export function tempo(parameters: tempo.Parameters = {}) {
export namespace tempo {
export type Parameters = charge_.Parameters & sessionIntent_.Parameters
+ /** Creates a Tempo `authorize` client method for deferred TIP-20 captures. */
+ export const authorize = authorize_
/** Creates a Tempo `charge` client method for one-time TIP-20 token transfers. */
export const charge = charge_
/** Creates a client-side streaming session for managing payment channels. */
export const session = session_
+ /** TIP20EscrowChannel precompile primitives for opt-in session implementations. */
+ export const precompile = Precompile_
/** Creates a Tempo `subscription` client method for recurring TIP-20 payments. */
export const subscription = subscription_
}
diff --git a/src/tempo/client/index.ts b/src/tempo/client/index.ts
index efb18180..e8a518e0 100644
--- a/src/tempo/client/index.ts
+++ b/src/tempo/client/index.ts
@@ -1,3 +1,4 @@
+export { authorize } from './Authorize.js'
export { charge } from './Charge.js'
export { tempo } from './Methods.js'
export { session } from './Session.js'
diff --git a/src/tempo/index.ts b/src/tempo/index.ts
index 65875ac7..da3616f1 100644
--- a/src/tempo/index.ts
+++ b/src/tempo/index.ts
@@ -1,4 +1,6 @@
export * as Proof from './Proof.js'
export * as Methods from './Methods.js'
+export * as Authorize from './authorize/index.js'
+export * as Precompile from './precompile/index.js'
export * as Session from './session/index.js'
export * as Subscription from './subscription/index.js'
diff --git a/src/tempo/precompile/Chain.integration.test.ts b/src/tempo/precompile/Chain.integration.test.ts
new file mode 100644
index 00000000..a048a6ac
--- /dev/null
+++ b/src/tempo/precompile/Chain.integration.test.ts
@@ -0,0 +1,321 @@
+import { Hex } from 'ox'
+import { type Address, encodeFunctionData, isAddressEqual, parseEventLogs, zeroAddress } from 'viem'
+import { sendTransaction, waitForTransactionReceipt } from 'viem/actions'
+import { Transaction } from 'viem/tempo'
+import { describe, expect, test } from 'vp/test'
+import { nodeEnv } from '~test/config.js'
+import { accounts, asset, chain, client } from '~test/tempo/viem.js'
+
+import * as Chain from './Chain.js'
+import * as Channel from './Channel.js'
+import { createOpenPayload, createTopUpPayload } from './client/ChannelOps.js'
+import { tip20ChannelEscrow } from './Constants.js'
+import { escrowAbi } from './escrow.abi.js'
+import { uint96 } from './Types.js'
+import * as Voucher from './Voucher.js'
+
+const isPrecompileTestnet = nodeEnv === 'localnet' || nodeEnv === 'devnet'
+const payer = accounts[2]
+const payee = accounts[0]
+const feePayer = accounts[1]
+
+async function sendPrecompileCall(data: Hex.Hex, account = payer) {
+ const hash = await sendTransaction(client, {
+ account,
+ chain,
+ to: tip20ChannelEscrow,
+ data,
+ gasPrice: 30_000_000_000n,
+ })
+ const receipt = await waitForTransactionReceipt(client, { hash })
+ expect(receipt.status).toBe('success')
+ return receipt
+}
+
+function getSingleEvent(receipt: { logs: readonly unknown[] }, name: string) {
+ const logs = parseEventLogs({
+ abi: escrowAbi,
+ eventName: name as never,
+ logs: receipt.logs as never,
+ })
+ expect(logs).toHaveLength(1)
+ return logs[0] as unknown as { args: Record }
+}
+
+async function openChannel(parameters: { deposit?: bigint | undefined } = {}) {
+ const deposit = uint96(parameters.deposit ?? 1_000n)
+ const salt = Hex.random(32)
+ const data = encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'open',
+ args: [payee.address, zeroAddress, asset, deposit, salt, payer.address],
+ })
+ const receipt = await sendPrecompileCall(data)
+ const opened = getSingleEvent(receipt, 'ChannelOpened')
+ const expiringNonceHash = opened.args.expiringNonceHash as Hex.Hex
+ const channelId = opened.args.channelId as Hex.Hex
+ const descriptor = {
+ payer: payer.address,
+ payee: payee.address,
+ operator: zeroAddress,
+ token: asset,
+ salt,
+ authorizedSigner: payer.address,
+ expiringNonceHash,
+ } satisfies Channel.ChannelDescriptor
+ expect(
+ Channel.computeId({
+ ...descriptor,
+ chainId: chain.id,
+ escrow: tip20ChannelEscrow,
+ }),
+ ).toBe(channelId)
+ return { channelId, descriptor, deposit }
+}
+
+describe.runIf(isPrecompileTestnet)('TIP20EscrowChannel precompile chain operations', () => {
+ test('opens a channel, parses ChannelOpened, and reads channel state', async () => {
+ const { channelId, descriptor, deposit } = await openChannel()
+
+ const state = await Chain.getChannelState(client, channelId, tip20ChannelEscrow)
+ expect(state.deposit).toBe(deposit)
+ expect(state.settled).toBe(0n)
+ expect(state.closeRequestedAt).toBe(0)
+
+ const channel = await Chain.getChannel(client, descriptor, tip20ChannelEscrow)
+ expect(isAddressEqual(channel.descriptor.payer as Address, payer.address)).toBe(true)
+ expect(isAddressEqual(channel.descriptor.payee as Address, payee.address)).toBe(true)
+ expect(channel.state.deposit).toBe(deposit)
+ })
+
+ test('topUp updates precompile channel state and emits TopUp', async () => {
+ const { channelId, descriptor, deposit } = await openChannel({
+ deposit: 1_000n,
+ })
+ const additionalDeposit = uint96(750n)
+
+ const receipt = await sendPrecompileCall(
+ encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'topUp',
+ args: [descriptor, additionalDeposit],
+ }),
+ )
+ const topUp = getSingleEvent(receipt, 'TopUp')
+ expect(topUp.args.channelId).toBe(channelId)
+ expect(topUp.args.additionalDeposit).toBe(additionalDeposit)
+ expect(topUp.args.newDeposit).toBe(deposit + additionalDeposit)
+
+ const state = await Chain.getChannelState(client, channelId, tip20ChannelEscrow)
+ expect(state.deposit).toBe(deposit + additionalDeposit)
+ })
+
+ test('broadcastOpenTransaction broadcasts a real client-signed open transaction', async () => {
+ const deposit = uint96(1_250n)
+ const payload = await createOpenPayload(client, payer, {
+ chainId: chain.id,
+ deposit,
+ initialAmount: uint96(100n),
+ payee: payee.address,
+ token: asset,
+ })
+ if (payload.action !== 'open') throw new Error('expected open payload')
+
+ const transaction = Transaction.deserialize(
+ payload.transaction as Transaction.TransactionSerializedTempo,
+ )
+ const expiringNonceHash = Channel.computeExpiringNonceHash(
+ transaction as Channel.ExpiringNonceTransaction,
+ { sender: payer.address },
+ )
+
+ const result = await Chain.broadcastOpenTransaction({
+ chainId: chain.id,
+ client,
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: payload.descriptor.authorizedSigner,
+ expectedChannelId: payload.channelId,
+ expectedCurrency: asset,
+ expectedExpiringNonceHash: expiringNonceHash,
+ expectedOperator: payload.descriptor.operator,
+ expectedPayee: payee.address,
+ expectedPayer: payer.address,
+ serializedTransaction: payload.transaction,
+ })
+
+ expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/i)
+ expect(isAddressEqual(result.descriptor.payer, payload.descriptor.payer)).toBe(true)
+ expect(isAddressEqual(result.descriptor.payee, payload.descriptor.payee)).toBe(true)
+ expect(isAddressEqual(result.descriptor.operator, payload.descriptor.operator)).toBe(true)
+ expect(isAddressEqual(result.descriptor.token, payload.descriptor.token)).toBe(true)
+ expect(
+ isAddressEqual(result.descriptor.authorizedSigner, payload.descriptor.authorizedSigner),
+ ).toBe(true)
+ expect(result.descriptor.salt).toBe(payload.descriptor.salt)
+ expect(result.descriptor.expiringNonceHash).toBe(payload.descriptor.expiringNonceHash)
+ expect(result.expiringNonceHash).toBe(expiringNonceHash)
+ expect(result.openDeposit).toBe(deposit)
+ expect(result.state.deposit).toBe(deposit)
+ expect(result.state.settled).toBe(0n)
+ expect(result.state.closeRequestedAt).toBe(0)
+ })
+
+ test('broadcastOpenTransaction broadcasts a real fee-sponsored open transaction', async () => {
+ const deposit = uint96(1_300n)
+ const payload = await createOpenPayload(client, payer, {
+ chainId: chain.id,
+ deposit,
+ feePayer: true,
+ initialAmount: uint96(100n),
+ payee: payee.address,
+ token: asset,
+ })
+ if (payload.action !== 'open') throw new Error('expected open payload')
+
+ const result = await Chain.broadcastOpenTransaction({
+ chainId: chain.id,
+ client,
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: payload.descriptor.authorizedSigner,
+ expectedChannelId: payload.channelId,
+ expectedCurrency: asset,
+ expectedExpiringNonceHash: payload.descriptor.expiringNonceHash,
+ expectedOperator: payload.descriptor.operator,
+ expectedPayee: payee.address,
+ expectedPayer: payer.address,
+ feePayer,
+ serializedTransaction: payload.transaction,
+ })
+
+ expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/i)
+ expect(result.openDeposit).toBe(deposit)
+ expect(result.state.deposit).toBe(deposit)
+ })
+
+ test('broadcastTopUpTransaction broadcasts a real client-signed top-up transaction', async () => {
+ const opened = await createOpenPayload(client, payer, {
+ chainId: chain.id,
+ deposit: uint96(1_000n),
+ initialAmount: uint96(100n),
+ payee: payee.address,
+ token: asset,
+ })
+ if (opened.action !== 'open') throw new Error('expected open payload')
+ await Chain.broadcastOpenTransaction({
+ chainId: chain.id,
+ client,
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: opened.descriptor.authorizedSigner,
+ expectedChannelId: opened.channelId,
+ expectedCurrency: asset,
+ expectedExpiringNonceHash: opened.descriptor.expiringNonceHash,
+ expectedOperator: opened.descriptor.operator,
+ expectedPayee: payee.address,
+ expectedPayer: payer.address,
+ serializedTransaction: opened.transaction,
+ })
+
+ const additionalDeposit = uint96(600n)
+ const topUp = await createTopUpPayload(
+ client,
+ payer,
+ opened.descriptor,
+ additionalDeposit,
+ chain.id,
+ )
+ if (topUp.action !== 'topUp') throw new Error('expected topUp payload')
+
+ const result = await Chain.broadcastTopUpTransaction({
+ additionalDeposit,
+ chainId: chain.id,
+ client,
+ descriptor: opened.descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: opened.channelId,
+ expectedCurrency: asset,
+ serializedTransaction: topUp.transaction,
+ })
+
+ expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/i)
+ expect(result.newDeposit).toBe(1_600n)
+ expect(result.state.deposit).toBe(1_600n)
+ })
+
+ test('broadcastTopUpTransaction broadcasts a real fee-sponsored top-up transaction', async () => {
+ const opened = await createOpenPayload(client, payer, {
+ chainId: chain.id,
+ deposit: uint96(1_000n),
+ initialAmount: uint96(100n),
+ payee: payee.address,
+ token: asset,
+ })
+ if (opened.action !== 'open') throw new Error('expected open payload')
+ await Chain.broadcastOpenTransaction({
+ chainId: chain.id,
+ client,
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: opened.descriptor.authorizedSigner,
+ expectedChannelId: opened.channelId,
+ expectedCurrency: asset,
+ expectedExpiringNonceHash: opened.descriptor.expiringNonceHash,
+ expectedOperator: opened.descriptor.operator,
+ expectedPayee: payee.address,
+ expectedPayer: payer.address,
+ serializedTransaction: opened.transaction,
+ })
+
+ const additionalDeposit = uint96(700n)
+ const topUp = await createTopUpPayload(
+ client,
+ payer,
+ opened.descriptor,
+ additionalDeposit,
+ chain.id,
+ true,
+ )
+ if (topUp.action !== 'topUp') throw new Error('expected topUp payload')
+
+ const result = await Chain.broadcastTopUpTransaction({
+ additionalDeposit,
+ chainId: chain.id,
+ client,
+ descriptor: opened.descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: opened.channelId,
+ expectedCurrency: asset,
+ feePayer,
+ serializedTransaction: topUp.transaction,
+ })
+
+ expect(result.txHash).toMatch(/^0x[0-9a-f]{64}$/i)
+ expect(result.newDeposit).toBe(1_700n)
+ expect(result.state.deposit).toBe(1_700n)
+ })
+
+ test('settles a signed voucher against the descriptor', async () => {
+ const { channelId, descriptor } = await openChannel({ deposit: 1_000n })
+ const cumulativeAmount = uint96(400n)
+ const signature = await Voucher.signVoucher(
+ client,
+ payer,
+ { channelId, cumulativeAmount },
+ tip20ChannelEscrow,
+ chain.id,
+ )
+
+ const receipt = await sendPrecompileCall(
+ encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'settle',
+ args: [descriptor, cumulativeAmount, signature],
+ }),
+ payee,
+ )
+ const settled = getSingleEvent(receipt, 'Settled')
+ expect(settled.args.channelId).toBe(channelId)
+ expect(settled.args.cumulativeAmount).toBe(cumulativeAmount)
+
+ const state = await Chain.getChannelState(client, channelId, tip20ChannelEscrow)
+ expect(state.settled).toBe(cumulativeAmount)
+ })
+})
diff --git a/src/tempo/precompile/Chain.test.ts b/src/tempo/precompile/Chain.test.ts
new file mode 100644
index 00000000..aee082d6
--- /dev/null
+++ b/src/tempo/precompile/Chain.test.ts
@@ -0,0 +1,922 @@
+import {
+ createClient,
+ custom,
+ encodeAbiParameters,
+ encodeEventTopics,
+ encodeFunctionData,
+ encodeFunctionResult,
+ erc20Abi,
+ zeroAddress,
+} from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { Transaction } from 'viem/tempo'
+import { describe, expect, test } from 'vp/test'
+
+import * as Chain from './Chain.js'
+import * as Channel from './Channel.js'
+import { tip20ChannelEscrow } from './Constants.js'
+import { escrowAbi } from './escrow.abi.js'
+import * as ServerChannelOps from './server/ChannelOps.js'
+import * as Types from './Types.js'
+
+const descriptor = {
+ payer: '0x1111111111111111111111111111111111111111',
+ payee: '0x2222222222222222222222222222222222222222',
+ operator: '0x3333333333333333333333333333333333333333',
+ token: '0x4444444444444444444444444444444444444444',
+ salt: '0x0000000000000000000000000000000000000000000000000000000000000001',
+ authorizedSigner: '0x5555555555555555555555555555555555555555',
+ expiringNonceHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+} as const
+
+const deposit = Types.uint96(1_000_000n)
+const chainId = 42431
+const txHash = `0x${'ab'.repeat(32)}` as const
+const feePayer = privateKeyToAccount(
+ '0x59c6995e998f97a5a0044966f0945389d1fc6e60e7346d6c36c49d32f75b9a1b',
+)
+const mockFeePayer = {
+ ...feePayer,
+ async signTransaction() {
+ return `0x76${'00'.repeat(32)}` as `0x${string}`
+ },
+}
+
+function createMockClient(
+ parameters: {
+ channel?: { descriptor: Channel.ChannelDescriptor; state: Chain.ChannelState } | undefined
+ receipt?: Record | null | undefined
+ rpcMethods?: string[] | undefined
+ } = {},
+) {
+ return createClient({
+ account: feePayer,
+ chain: { id: chainId } as never,
+ transport: custom({
+ async request(args) {
+ parameters.rpcMethods?.push(args.method)
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
+ if (args.method === 'eth_sendRawTransaction') return txHash
+ if (args.method === 'eth_sendRawTransactionSync') return parameters.receipt ?? receipt([])
+ if (args.method === 'eth_getTransactionReceipt') return parameters.receipt ?? null
+ if (args.method === 'eth_call') {
+ const data = (args.params as [{ data?: `0x${string}` }])[0].data
+ const channel = parameters.channel
+ if (!channel || !data) return '0x'
+ const selector = data.slice(0, 10)
+ const getChannelSelector = encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'getChannel',
+ args: [channel.descriptor],
+ }).slice(0, 10)
+ if (selector === getChannelSelector)
+ return encodeFunctionResult({
+ abi: escrowAbi,
+ functionName: 'getChannel',
+ result: { descriptor: channel.descriptor, state: channel.state },
+ })
+ return encodeFunctionResult({
+ abi: escrowAbi,
+ functionName: 'getChannelState',
+ result: channel.state,
+ })
+ }
+ throw new Error(`unexpected rpc request: ${args.method}`)
+ },
+ }),
+ })
+}
+
+function receipt(logs: readonly Record[]) {
+ return {
+ blockHash: `0x${'01'.repeat(32)}`,
+ blockNumber: '0x1',
+ contractAddress: null,
+ cumulativeGasUsed: '0x1',
+ effectiveGasPrice: '0x1',
+ from: descriptor.payer,
+ gasUsed: '0x1',
+ logs,
+ logsBloom: `0x${'00'.repeat(256)}`,
+ status: '0x1',
+ to: tip20ChannelEscrow,
+ transactionHash: txHash,
+ transactionIndex: '0x0',
+ type: '0x76',
+ }
+}
+
+function openedLog(parameters: {
+ channelId: `0x${string}`
+ expiringNonceHash: `0x${string}`
+ deposit?: bigint | undefined
+}) {
+ return {
+ address: tip20ChannelEscrow,
+ data: encodeAbiParameters(
+ [
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'bytes32' },
+ { type: 'bytes32' },
+ { type: 'uint96' },
+ ],
+ [
+ descriptor.operator,
+ descriptor.token,
+ descriptor.authorizedSigner,
+ descriptor.salt,
+ parameters.expiringNonceHash,
+ parameters.deposit ?? deposit,
+ ],
+ ),
+ topics: encodeEventTopics({
+ abi: escrowAbi,
+ eventName: 'ChannelOpened',
+ args: { channelId: parameters.channelId, payer: descriptor.payer, payee: descriptor.payee },
+ }),
+ }
+}
+
+function topUpLog(parameters: { channelId: `0x${string}`; newDeposit: bigint }) {
+ return {
+ address: tip20ChannelEscrow,
+ data: encodeAbiParameters(
+ [{ type: 'uint96' }, { type: 'uint96' }],
+ [deposit, parameters.newDeposit],
+ ),
+ topics: encodeEventTopics({
+ abi: escrowAbi,
+ eventName: 'TopUp',
+ args: { channelId: parameters.channelId, payer: descriptor.payer, payee: descriptor.payee },
+ }),
+ }
+}
+
+async function createSerializedTransaction(parameters: {
+ calls: { to: `0x${string}`; data: `0x${string}` }[]
+ gas?: bigint | undefined
+ signed?: boolean | undefined
+}) {
+ return (await Transaction.serialize({
+ chainId,
+ calls: parameters.calls,
+ feeToken: descriptor.token,
+ nonce: 0,
+ ...(parameters.gas !== undefined ? { gas: parameters.gas } : {}),
+ ...(parameters.signed
+ ? {
+ maxFeePerGas: 1n,
+ maxPriorityFeePerGas: 1n,
+ nonceKey: 1n,
+ validBefore: Math.floor(Date.now() / 1_000) + 600,
+ }
+ : {}),
+ ...(parameters.signed
+ ? {
+ signature: {
+ r: `0x${'01'.repeat(32)}` as `0x${string}`,
+ s: `0x${'02'.repeat(32)}` as `0x${string}`,
+ yParity: 0,
+ },
+ }
+ : {}),
+ } as never)) as `0x${string}`
+}
+
+async function createOpenTransaction(
+ parameters: {
+ authorizedSigner?: `0x${string}` | undefined
+ gas?: bigint | undefined
+ operator?: `0x${string}` | undefined
+ payee?: `0x${string}` | undefined
+ signed?: boolean | undefined
+ token?: `0x${string}` | undefined
+ to?: `0x${string}` | undefined
+ } = {},
+) {
+ const data = encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'open',
+ args: [
+ parameters.payee ?? descriptor.payee,
+ parameters.operator ?? descriptor.operator,
+ parameters.token ?? descriptor.token,
+ deposit,
+ descriptor.salt,
+ parameters.authorizedSigner ?? descriptor.authorizedSigner,
+ ],
+ })
+ return createSerializedTransaction({
+ calls: [{ to: parameters.to ?? tip20ChannelEscrow, data }],
+ gas: parameters.gas,
+ signed: parameters.signed,
+ })
+}
+
+async function createTopUpTransaction(
+ parameters: {
+ additionalDeposit?: bigint | undefined
+ descriptor_?: Channel.ChannelDescriptor | undefined
+ gas?: bigint | undefined
+ signed?: boolean | undefined
+ to?: `0x${string}` | undefined
+ } = {},
+) {
+ const data = encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'topUp',
+ args: [
+ parameters.descriptor_ ?? descriptor,
+ Types.uint96(parameters.additionalDeposit ?? deposit),
+ ],
+ })
+ return createSerializedTransaction({
+ calls: [{ to: parameters.to ?? tip20ChannelEscrow, data }],
+ gas: parameters.gas,
+ signed: parameters.signed,
+ })
+}
+
+function expectedExpiringNonceHash(serializedTransaction: `0x${string}`) {
+ return Channel.computeExpiringNonceHash(
+ Transaction.deserialize(
+ serializedTransaction as Transaction.TransactionSerializedTempo,
+ ) as Channel.ExpiringNonceTransaction,
+ { sender: descriptor.payer },
+ )
+}
+
+describe('precompile open calldata parsing', () => {
+ test('parseOpenCall accepts TIP-1034 open calldata', () => {
+ const data = encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'open',
+ args: [
+ descriptor.payee,
+ descriptor.operator,
+ descriptor.token,
+ deposit,
+ descriptor.salt,
+ descriptor.authorizedSigner,
+ ],
+ })
+ const open = ServerChannelOps.parseOpenCall({
+ data,
+ expected: {
+ authorizedSigner: descriptor.authorizedSigner,
+ deposit,
+ operator: descriptor.operator,
+ payee: descriptor.payee,
+ token: descriptor.token,
+ },
+ })
+ expect(open).toEqual({
+ authorizedSigner: descriptor.authorizedSigner,
+ deposit,
+ operator: descriptor.operator,
+ payee: descriptor.payee,
+ salt: descriptor.salt,
+ token: descriptor.token,
+ })
+ })
+
+ test('parseOpenCall rejects non-open calldata and expected mismatches', () => {
+ const approve = encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [descriptor.payee, deposit],
+ })
+ expect(() => ServerChannelOps.parseOpenCall({ data: approve })).toThrow(
+ 'Expected TIP-1034 open calldata',
+ )
+
+ const data = encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'open',
+ args: [
+ descriptor.payee,
+ descriptor.operator,
+ descriptor.token,
+ deposit,
+ descriptor.salt,
+ descriptor.authorizedSigner,
+ ],
+ })
+ expect(() =>
+ ServerChannelOps.parseOpenCall({
+ data,
+ expected: { payee: '0xffffffffffffffffffffffffffffffffffffffff' },
+ }),
+ ).toThrow('payee does not match')
+ expect(() =>
+ ServerChannelOps.parseOpenCall({
+ data,
+ expected: { operator: '0xffffffffffffffffffffffffffffffffffffffff' },
+ }),
+ ).toThrow('operator does not match')
+ expect(() =>
+ ServerChannelOps.parseOpenCall({
+ data,
+ expected: { token: '0xffffffffffffffffffffffffffffffffffffffff' },
+ }),
+ ).toThrow('token does not match')
+ expect(() =>
+ ServerChannelOps.parseOpenCall({
+ data,
+ expected: { authorizedSigner: '0xffffffffffffffffffffffffffffffffffffffff' },
+ }),
+ ).toThrow('authorizedSigner does not match')
+ expect(() =>
+ ServerChannelOps.parseOpenCall({ data, expected: { deposit: Types.uint96(1n) } }),
+ ).toThrow('deposit does not match')
+ })
+})
+
+describe('precompile broadcastOpenTransaction', () => {
+ test('rejects transactions with extra calls', async () => {
+ const serializedTransaction = await createSerializedTransaction({
+ calls: [
+ {
+ to: tip20ChannelEscrow,
+ data: encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'open',
+ args: [
+ descriptor.payee,
+ descriptor.operator,
+ descriptor.token,
+ deposit,
+ descriptor.salt,
+ descriptor.authorizedSigner,
+ ],
+ }),
+ },
+ {
+ to: descriptor.token,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [tip20ChannelEscrow, deposit],
+ }),
+ },
+ ],
+ })
+
+ await expect(
+ Chain.broadcastOpenTransaction({
+ chainId,
+ client: createMockClient(),
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
+ expectedChannelId: `0x${'11'.repeat(32)}`,
+ expectedCurrency: descriptor.token,
+ expectedExpiringNonceHash: `0x${'aa'.repeat(32)}`,
+ expectedOperator: descriptor.operator,
+ expectedPayee: descriptor.payee,
+ expectedPayer: descriptor.payer,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('TIP-1034 open transaction must contain exactly one call')
+ })
+
+ test('rejects open transactions targeting the wrong escrow contract', async () => {
+ const serializedTransaction = await createOpenTransaction({ to: descriptor.token })
+
+ await expect(
+ Chain.broadcastOpenTransaction({
+ chainId,
+ client: createMockClient(),
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
+ expectedChannelId: `0x${'11'.repeat(32)}`,
+ expectedCurrency: descriptor.token,
+ expectedExpiringNonceHash: expectedExpiringNonceHash(serializedTransaction),
+ expectedOperator: descriptor.operator,
+ expectedPayee: descriptor.payee,
+ expectedPayer: descriptor.payer,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('TIP-1034 open transaction targets the wrong address')
+ })
+
+ test('rejects open calldata mismatches before broadcasting', async () => {
+ const serializedTransaction = await createOpenTransaction({ payee: zeroAddress })
+
+ await expect(
+ Chain.broadcastOpenTransaction({
+ chainId,
+ client: createMockClient(),
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
+ expectedChannelId: `0x${'11'.repeat(32)}`,
+ expectedCurrency: descriptor.token,
+ expectedExpiringNonceHash: expectedExpiringNonceHash(serializedTransaction),
+ expectedOperator: descriptor.operator,
+ expectedPayee: descriptor.payee,
+ expectedPayer: descriptor.payer,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('payee does not match')
+ })
+
+ test('fee-payer rejects non-Tempo open transactions', async () => {
+ await expect(
+ Chain.broadcastOpenTransaction({
+ chainId,
+ client: createMockClient(),
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
+ expectedChannelId: `0x${'11'.repeat(32)}`,
+ expectedCurrency: descriptor.token,
+ expectedExpiringNonceHash: `0x${'aa'.repeat(32)}`,
+ expectedOperator: descriptor.operator,
+ expectedPayee: descriptor.payee,
+ expectedPayer: descriptor.payer,
+ feePayer: { address: descriptor.payee } as never,
+ serializedTransaction: '0x1234',
+ }),
+ ).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
+ })
+
+ test('fee-payer rejects unsigned open transactions', async () => {
+ const serializedTransaction = await createOpenTransaction()
+ const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction)
+ const expectedDescriptor = { ...descriptor, expiringNonceHash }
+ const channelId = Channel.computeId({
+ ...expectedDescriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ })
+
+ await expect(
+ Chain.broadcastOpenTransaction({
+ chainId,
+ client: createMockClient(),
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
+ expectedChannelId: channelId,
+ expectedCurrency: descriptor.token,
+ expectedExpiringNonceHash: expiringNonceHash,
+ expectedOperator: descriptor.operator,
+ expectedPayee: descriptor.payee,
+ expectedPayer: descriptor.payer,
+ feePayer: { address: descriptor.payee } as never,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('Transaction must be signed by the sender before fee payer co-signing')
+ })
+
+ test('fee-payer rejects open transactions whose gas budget exceeds sponsor policy', async () => {
+ const serializedTransaction = await createOpenTransaction({ gas: 2_000_001n, signed: true })
+ const transaction = Transaction.deserialize(
+ serializedTransaction as Transaction.TransactionSerializedTempo,
+ )
+ const payer = transaction.from!
+ const expiringNonceHash = Channel.computeExpiringNonceHash(
+ transaction as Channel.ExpiringNonceTransaction,
+ { sender: payer },
+ )
+ const expectedDescriptor = { ...descriptor, payer, expiringNonceHash }
+ const channelId = Channel.computeId({
+ ...expectedDescriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ })
+
+ await expect(
+ Chain.broadcastOpenTransaction({
+ chainId,
+ client: createMockClient(),
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
+ expectedChannelId: channelId,
+ expectedCurrency: descriptor.token,
+ expectedExpiringNonceHash: expiringNonceHash,
+ expectedOperator: descriptor.operator,
+ expectedPayee: descriptor.payee,
+ expectedPayer: payer,
+ feePayer,
+ feePayerPolicy: { maxGas: 2_000_000n },
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('fee-sponsored transaction gas exceeds sponsor policy')
+ })
+
+ test('fee-payer simulates open before raw broadcast', async () => {
+ const rpcMethods: string[] = []
+ const serializedTransaction = await createOpenTransaction({ gas: 100_000n, signed: true })
+ const transaction = Transaction.deserialize(
+ serializedTransaction as Transaction.TransactionSerializedTempo,
+ )
+ const payer = transaction.from!
+ const expiringNonceHash = Channel.computeExpiringNonceHash(
+ transaction as Channel.ExpiringNonceTransaction,
+ { sender: payer },
+ )
+ const expectedDescriptor = { ...descriptor, payer, expiringNonceHash }
+ const channelId = Channel.computeId({
+ ...expectedDescriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ })
+ const state = { settled: 0n, deposit, closeRequestedAt: 0 }
+
+ await Chain.broadcastOpenTransaction({
+ chainId,
+ client: createMockClient({
+ channel: { descriptor: expectedDescriptor, state },
+ receipt: receipt([openedLog({ channelId, expiringNonceHash })]),
+ rpcMethods,
+ }),
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
+ expectedChannelId: channelId,
+ expectedCurrency: descriptor.token,
+ expectedExpiringNonceHash: expiringNonceHash,
+ expectedOperator: descriptor.operator,
+ expectedPayee: descriptor.payee,
+ expectedPayer: payer,
+ feePayer: mockFeePayer,
+ serializedTransaction,
+ })
+
+ const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransactionSync')
+ const simulationIndex = rpcMethods.indexOf('eth_call')
+ expect(broadcastIndex).toBeGreaterThan(-1)
+ expect(simulationIndex).toBeGreaterThan(-1)
+ expect(simulationIndex).toBeLessThan(broadcastIndex)
+ })
+
+ test('rejects expiring nonce hash mismatches before broadcasting', async () => {
+ const serializedTransaction = await createOpenTransaction()
+
+ const wrongExpiringNonceHash = `0x${'bb'.repeat(32)}` as const
+ const expectedChannelId = Channel.computeId({
+ ...descriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ expiringNonceHash: wrongExpiringNonceHash,
+ })
+
+ await expect(
+ Chain.broadcastOpenTransaction({
+ chainId,
+ client: createMockClient(),
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
+ expectedChannelId,
+ expectedCurrency: descriptor.token,
+ expectedExpiringNonceHash: wrongExpiringNonceHash,
+ expectedOperator: descriptor.operator,
+ expectedPayee: descriptor.payee,
+ expectedPayer: descriptor.payer,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('credential expiringNonceHash does not match transaction')
+ })
+
+ test('returns tx hash, descriptor, event fields, and read-back state on success', async () => {
+ const serializedTransaction = await createOpenTransaction()
+ const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction)
+ const expectedDescriptor = { ...descriptor, expiringNonceHash }
+ const channelId = Channel.computeId({
+ ...expectedDescriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ })
+ const state = { settled: 0n, deposit, closeRequestedAt: 0 }
+
+ const result = await Chain.broadcastOpenTransaction({
+ chainId,
+ client: createMockClient({
+ channel: { descriptor: expectedDescriptor, state },
+ receipt: receipt([openedLog({ channelId, expiringNonceHash })]),
+ }),
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
+ expectedChannelId: channelId,
+ expectedCurrency: descriptor.token,
+ expectedExpiringNonceHash: expiringNonceHash,
+ expectedOperator: descriptor.operator,
+ expectedPayee: descriptor.payee,
+ expectedPayer: descriptor.payer,
+ serializedTransaction,
+ })
+
+ expect(result).toEqual({
+ txHash,
+ descriptor: expectedDescriptor,
+ state,
+ expiringNonceHash,
+ openDeposit: deposit,
+ })
+ })
+
+ test('rejects ChannelOpened receipt deposit mismatches', async () => {
+ const serializedTransaction = await createOpenTransaction()
+ const expiringNonceHash = expectedExpiringNonceHash(serializedTransaction)
+ const expectedDescriptor = { ...descriptor, expiringNonceHash }
+ const channelId = Channel.computeId({
+ ...expectedDescriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ })
+
+ await expect(
+ Chain.broadcastOpenTransaction({
+ chainId,
+ client: createMockClient({
+ channel: {
+ descriptor: expectedDescriptor,
+ state: { settled: 0n, deposit, closeRequestedAt: 0 },
+ },
+ receipt: receipt([openedLog({ channelId, expiringNonceHash, deposit: deposit + 1n })]),
+ }),
+ escrowContract: tip20ChannelEscrow,
+ expectedAuthorizedSigner: descriptor.authorizedSigner,
+ expectedChannelId: channelId,
+ expectedCurrency: descriptor.token,
+ expectedExpiringNonceHash: expiringNonceHash,
+ expectedOperator: descriptor.operator,
+ expectedPayee: descriptor.payee,
+ expectedPayer: descriptor.payer,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('ChannelOpened deposit does not match calldata')
+ })
+})
+
+describe('precompile broadcastTopUpTransaction', () => {
+ test('rejects transactions with extra calls', async () => {
+ const serializedTransaction = await createSerializedTransaction({
+ calls: [
+ {
+ to: tip20ChannelEscrow,
+ data: encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'topUp',
+ args: [descriptor, deposit],
+ }),
+ },
+ {
+ to: descriptor.token,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [tip20ChannelEscrow, deposit],
+ }),
+ },
+ ],
+ })
+
+ await expect(
+ Chain.broadcastTopUpTransaction({
+ additionalDeposit: deposit,
+ chainId,
+ client: createMockClient(),
+ descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: `0x${'11'.repeat(32)}`,
+ expectedCurrency: descriptor.token,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('TIP-1034 topUp transaction must contain exactly one call')
+ })
+
+ test('rejects top-up transactions targeting the wrong escrow contract', async () => {
+ const serializedTransaction = await createTopUpTransaction({ to: descriptor.token })
+
+ await expect(
+ Chain.broadcastTopUpTransaction({
+ additionalDeposit: deposit,
+ chainId,
+ client: createMockClient(),
+ descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: `0x${'11'.repeat(32)}`,
+ expectedCurrency: descriptor.token,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('TIP-1034 topUp transaction targets the wrong address')
+ })
+
+ test('rejects top-up calldata descriptor mismatches before broadcasting', async () => {
+ const serializedTransaction = await createTopUpTransaction({
+ descriptor_: { ...descriptor, payee: zeroAddress },
+ })
+
+ await expect(
+ Chain.broadcastTopUpTransaction({
+ additionalDeposit: deposit,
+ chainId,
+ client: createMockClient(),
+ descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: `0x${'11'.repeat(32)}`,
+ expectedCurrency: descriptor.token,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('descriptor does not match')
+ })
+
+ test('fee-payer rejects non-Tempo top-up transactions', async () => {
+ await expect(
+ Chain.broadcastTopUpTransaction({
+ additionalDeposit: deposit,
+ chainId,
+ client: createMockClient(),
+ descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: `0x${'11'.repeat(32)}`,
+ expectedCurrency: descriptor.token,
+ feePayer: { address: descriptor.payee } as never,
+ serializedTransaction: '0x1234',
+ }),
+ ).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
+ })
+
+ test('fee-payer rejects unsigned top-up transactions', async () => {
+ const serializedTransaction = await createTopUpTransaction()
+
+ await expect(
+ Chain.broadcastTopUpTransaction({
+ additionalDeposit: deposit,
+ chainId,
+ client: createMockClient(),
+ descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: Channel.computeId({
+ ...descriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ }),
+ expectedCurrency: descriptor.token,
+ feePayer: { address: descriptor.payee } as never,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('Transaction must be signed by the sender before fee payer co-signing')
+ })
+
+ test('fee-payer rejects top-up transactions whose gas budget exceeds sponsor policy', async () => {
+ const serializedTransaction = await createTopUpTransaction({ gas: 2_000_001n, signed: true })
+
+ await expect(
+ Chain.broadcastTopUpTransaction({
+ additionalDeposit: deposit,
+ chainId,
+ client: createMockClient(),
+ descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: Channel.computeId({
+ ...descriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ }),
+ expectedCurrency: descriptor.token,
+ feePayer,
+ feePayerPolicy: { maxGas: 2_000_000n },
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('fee-sponsored transaction gas exceeds sponsor policy')
+ })
+
+ test('fee-payer simulates top-up before raw broadcast', async () => {
+ const rpcMethods: string[] = []
+ const serializedTransaction = await createTopUpTransaction({ gas: 100_000n, signed: true })
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
+ const newDeposit = deposit * 2n
+ await Chain.broadcastTopUpTransaction({
+ additionalDeposit: deposit,
+ chainId,
+ client: createMockClient({
+ channel: { descriptor, state: { settled: 0n, deposit: newDeposit, closeRequestedAt: 0 } },
+ receipt: receipt([topUpLog({ channelId, newDeposit })]),
+ rpcMethods,
+ }),
+ descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: channelId,
+ expectedCurrency: descriptor.token,
+ feePayer: mockFeePayer,
+ serializedTransaction,
+ })
+
+ const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransactionSync')
+ const simulationIndex = rpcMethods.indexOf('eth_call')
+ expect(broadcastIndex).toBeGreaterThan(-1)
+ expect(simulationIndex).toBeGreaterThan(-1)
+ expect(simulationIndex).toBeLessThan(broadcastIndex)
+ })
+
+ test('rejects top-up calldata amount mismatches before broadcasting', async () => {
+ const serializedTransaction = await createTopUpTransaction({ additionalDeposit: 1n })
+
+ await expect(
+ Chain.broadcastTopUpTransaction({
+ additionalDeposit: deposit,
+ chainId,
+ client: createMockClient(),
+ descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: `0x${'11'.repeat(32)}`,
+ expectedCurrency: descriptor.token,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('topUp deposit does not match credential')
+ })
+
+ test('returns tx hash, new deposit, and read-back state on success', async () => {
+ const serializedTransaction = await createTopUpTransaction()
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
+ const newDeposit = deposit * 2n
+ const state = { settled: 0n, deposit: newDeposit, closeRequestedAt: 0 }
+
+ const result = await Chain.broadcastTopUpTransaction({
+ additionalDeposit: deposit,
+ chainId,
+ client: createMockClient({
+ channel: { descriptor, state },
+ receipt: receipt([topUpLog({ channelId, newDeposit })]),
+ }),
+ descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: channelId,
+ expectedCurrency: descriptor.token,
+ serializedTransaction,
+ })
+
+ expect(result).toEqual({ txHash, newDeposit, state })
+ })
+
+ test('rejects top-up receipt/readback deposit mismatches', async () => {
+ const serializedTransaction = await createTopUpTransaction()
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
+
+ await expect(
+ Chain.broadcastTopUpTransaction({
+ additionalDeposit: deposit,
+ chainId,
+ client: createMockClient({
+ channel: { descriptor, state: { settled: 0n, deposit: deposit, closeRequestedAt: 0 } },
+ receipt: receipt([topUpLog({ channelId, newDeposit: deposit * 2n })]),
+ }),
+ descriptor,
+ escrowContract: tip20ChannelEscrow,
+ expectedChannelId: channelId,
+ expectedCurrency: descriptor.token,
+ serializedTransaction,
+ }),
+ ).rejects.toThrow('on-chain channel state does not match topUp receipt')
+ })
+})
+
+describe('precompile escrowAbi parity', () => {
+ test('contains all TIP-1034 functions and events', () => {
+ const functions = escrowAbi.filter((item) => item.type === 'function').map((item) => item.name)
+ expect(functions).toEqual([
+ 'CLOSE_GRACE_PERIOD',
+ 'VOUCHER_TYPEHASH',
+ 'open',
+ 'settle',
+ 'topUp',
+ 'close',
+ 'requestClose',
+ 'withdraw',
+ 'getChannel',
+ 'getChannelState',
+ 'getChannelStatesBatch',
+ 'computeChannelId',
+ 'getVoucherDigest',
+ 'domainSeparator',
+ ])
+
+ const events = escrowAbi.filter((item) => item.type === 'event').map((item) => item.name)
+ expect(events).toEqual([
+ 'ChannelOpened',
+ 'Settled',
+ 'TopUp',
+ 'CloseRequested',
+ 'ChannelClosed',
+ 'CloseRequestCancelled',
+ ])
+ })
+
+ test('keeps ChannelDescriptor component order and ChannelOpened expiringNonceHash', () => {
+ const settle = escrowAbi.find((item) => item.type === 'function' && item.name === 'settle')!
+ const descriptorInput = settle.inputs[0]
+ expect(descriptorInput.components.map((component) => component.name)).toEqual([
+ 'payer',
+ 'payee',
+ 'operator',
+ 'token',
+ 'salt',
+ 'authorizedSigner',
+ 'expiringNonceHash',
+ ])
+
+ const opened = escrowAbi.find((item) => item.type === 'event' && item.name === 'ChannelOpened')!
+ expect(opened.inputs.map((input) => input.name)).toContain('expiringNonceHash')
+ })
+})
diff --git a/src/tempo/precompile/Chain.ts b/src/tempo/precompile/Chain.ts
new file mode 100644
index 00000000..336d2c16
--- /dev/null
+++ b/src/tempo/precompile/Chain.ts
@@ -0,0 +1,647 @@
+import type { Account, Address, Client, Hex } from 'viem'
+import { encodeFunctionData, isAddressEqual, parseEventLogs } from 'viem'
+import {
+ call,
+ prepareTransactionRequest,
+ readContract,
+ sendRawTransaction,
+ sendRawTransactionSync,
+ sendTransaction as sendViemTransaction,
+ signTransaction,
+ waitForTransactionReceipt,
+} from 'viem/actions'
+import { Transaction } from 'viem/tempo'
+
+import { BadRequestError, VerificationFailedError } from '../../Errors.js'
+import * as FeePayer from '../internal/fee-payer.js'
+import { resolveFeeToken } from '../internal/fee-token.js'
+import * as ChannelUtils from './Channel.js'
+import type { ChannelDescriptor } from './Channel.js'
+import { tip20ChannelEscrow } from './Constants.js'
+import { escrowAbi } from './escrow.abi.js'
+import * as ChannelOps from './server/ChannelOps.js'
+
+const UINT96_MAX = 2n ** 96n - 1n
+
+/** viem client shape accepted by raw Tempo transaction actions. */
+export type TransactionClient = Parameters[0]
+
+function assertUint96(amount: bigint): void {
+ if (amount < 0n || amount > UINT96_MAX) {
+ throw new VerificationFailedError({ reason: 'amount exceeds uint96 range' })
+ }
+}
+
+function uint96(amount: bigint): bigint {
+ assertUint96(amount)
+ return amount
+}
+
+/**
+ * On-chain channel state from the TIP20EscrowChannel precompile.
+ */
+export type ChannelState = {
+ settled: bigint
+ deposit: bigint
+ closeRequestedAt: number
+}
+
+/**
+ * On-chain channel descriptor and state from the TIP20EscrowChannel precompile.
+ */
+export type Channel = {
+ descriptor: ChannelDescriptor
+ state: ChannelState
+}
+
+/**
+ * Read channel descriptor and state from the TIP20EscrowChannel precompile.
+ */
+export async function getChannel(
+ client: Client,
+ descriptor: ChannelDescriptor,
+ escrow: Address = tip20ChannelEscrow,
+): Promise {
+ const channel = await readContract(client, {
+ address: escrow,
+ abi: escrowAbi,
+ functionName: 'getChannel',
+ args: [descriptorTuple(descriptor)],
+ })
+ return {
+ descriptor: channel.descriptor,
+ state: stateFromTuple(channel.state),
+ }
+}
+
+/**
+ * Read channel state from the TIP20EscrowChannel precompile.
+ */
+export async function getChannelState(
+ client: Client,
+ channelId: Hex,
+ escrow: Address = tip20ChannelEscrow,
+): Promise {
+ const state = await readContract(client, {
+ address: escrow,
+ abi: escrowAbi,
+ functionName: 'getChannelState',
+ args: [channelId],
+ })
+ return stateFromTuple(state)
+}
+
+/**
+ * Read channel states from the TIP20EscrowChannel precompile.
+ */
+export async function getChannelStatesBatch(
+ client: Client,
+ channelIds: readonly Hex[],
+ escrow: Address = tip20ChannelEscrow,
+): Promise {
+ const states = await readContract(client, {
+ address: escrow,
+ abi: escrowAbi,
+ functionName: 'getChannelStatesBatch',
+ args: [channelIds],
+ })
+ return states.map(stateFromTuple)
+}
+
+type SendOptions = {
+ account?: Account | undefined
+ candidateFeeTokens?: readonly Address[] | undefined
+ feePayer?: Account | undefined
+ feePayerPolicy?: Partial | undefined
+ feeToken?: Address | undefined
+}
+
+/**
+ * Submit a settle transaction on-chain.
+ */
+export async function settleOnChain(
+ client: Client,
+ descriptor: ChannelDescriptor,
+ cumulativeAmount: bigint,
+ signature: Hex,
+ escrow: Address = tip20ChannelEscrow,
+ options?: SendOptions,
+): Promise {
+ assertUint96(cumulativeAmount)
+ const args = [descriptorTuple(descriptor), cumulativeAmount, signature] as const
+ return sendPrecompileTransaction(
+ client,
+ escrow,
+ encodeFunctionData({ abi: escrowAbi, functionName: 'settle', args }),
+ 'settle',
+ options,
+ )
+}
+
+/**
+ * Submit a top-up transaction on-chain.
+ */
+export async function topUpOnChain(
+ client: Client,
+ descriptor: ChannelDescriptor,
+ additionalDeposit: bigint,
+ escrow: Address = tip20ChannelEscrow,
+ options?: SendOptions,
+): Promise {
+ assertUint96(additionalDeposit)
+ const args = [descriptorTuple(descriptor), additionalDeposit] as const
+ return sendPrecompileTransaction(
+ client,
+ escrow,
+ encodeFunctionData({ abi: escrowAbi, functionName: 'topUp', args }),
+ 'topUp',
+ options,
+ )
+}
+
+/**
+ * Submit a request-close transaction on-chain.
+ */
+export async function requestCloseOnChain(
+ client: Client,
+ descriptor: ChannelDescriptor,
+ escrow: Address = tip20ChannelEscrow,
+ options?: SendOptions,
+): Promise {
+ const args = [descriptorTuple(descriptor)] as const
+ return sendPrecompileTransaction(
+ client,
+ escrow,
+ encodeFunctionData({ abi: escrowAbi, functionName: 'requestClose', args }),
+ 'requestClose',
+ options,
+ )
+}
+
+/**
+ * Submit a withdraw transaction on-chain.
+ */
+export async function withdrawOnChain(
+ client: Client,
+ descriptor: ChannelDescriptor,
+ escrow: Address = tip20ChannelEscrow,
+ options?: SendOptions,
+): Promise {
+ const args = [descriptorTuple(descriptor)] as const
+ return sendPrecompileTransaction(
+ client,
+ escrow,
+ encodeFunctionData({ abi: escrowAbi, functionName: 'withdraw', args }),
+ 'withdraw',
+ options,
+ )
+}
+
+/**
+ * Submit a close transaction on-chain.
+ */
+export async function closeOnChain(
+ client: Client,
+ descriptor: ChannelDescriptor,
+ cumulativeAmount: bigint,
+ captureAmount: bigint,
+ signature: Hex,
+ escrow: Address = tip20ChannelEscrow,
+ options?: SendOptions,
+): Promise {
+ assertUint96(cumulativeAmount)
+ assertUint96(captureAmount)
+ const args = [descriptorTuple(descriptor), cumulativeAmount, captureAmount, signature] as const
+ return sendPrecompileTransaction(
+ client,
+ escrow,
+ encodeFunctionData({ abi: escrowAbi, functionName: 'close', args }),
+ 'close',
+ options,
+ )
+}
+
+/** Receipt event shape emitted by TIP20EscrowChannel precompile management calls. */
+export type ChannelReceiptEvent = {
+ args: {
+ channelId: Hex
+ expiringNonceHash?: Hex | undefined
+ deposit?: bigint | undefined
+ newDeposit?: bigint | undefined
+ newSettled?: bigint | undefined
+ settledToPayee?: bigint | undefined
+ refundedToPayer?: bigint | undefined
+ }
+}
+
+/**
+ * Asserts that a deserialized transaction has an existing sender signature.
+ */
+export function assertSenderSigned(
+ transaction: ReturnType<(typeof Transaction)['deserialize']>,
+): void {
+ if (!transaction.signature || !transaction.from)
+ throw new BadRequestError({
+ reason: 'Transaction must be signed by the sender before fee payer co-signing',
+ })
+}
+
+/** Broadcast a raw serialized transaction. */
+export async function sendTransaction(client: TransactionClient, transaction: Hex) {
+ return sendRawTransaction(client, { serializedTransaction: transaction })
+}
+
+/** Wait for a receipt and reject reverted precompile transactions. */
+export async function waitForSuccessfulReceipt(client: TransactionClient, hash: Hex) {
+ const receipt = await waitForTransactionReceipt(client, { hash })
+ if (receipt.status !== 'success')
+ throw new VerificationFailedError({ reason: 'precompile transaction reverted' })
+ return receipt
+}
+
+/** Extract exactly one channel event for a channel ID from a receipt. */
+export function getChannelEvent(
+ receipt: { logs: Parameters[0]['logs'] },
+ name: 'ChannelOpened' | 'TopUp' | 'Settled' | 'ChannelClosed',
+ channelId: Hex,
+): ChannelReceiptEvent {
+ const logs = parseEventLogs({
+ abi: escrowAbi,
+ eventName: name,
+ logs: receipt.logs,
+ }) as ChannelReceiptEvent[]
+ const matches = logs.filter((log) => log.args.channelId.toLowerCase() === channelId.toLowerCase())
+ if (matches.length !== 1)
+ throw new VerificationFailedError({
+ reason: `expected one ${name} event for credential channelId in receipt`,
+ })
+ return matches[0]!
+}
+
+/** Broadcasts a client-signed management transaction, adding a fee-payer co-signature when requested. */
+export async function sendCredentialTransaction(parameters: {
+ challengeExpires?: string | undefined
+ chainId: number
+ client: TransactionClient
+ details: Record
+ expectedFeeToken?: Address | undefined
+ feePayer?: Account | undefined
+ feePayerPolicy?: Partial | undefined
+ label: 'open' | 'topUp'
+ serializedTransaction: Hex
+ transaction: ReturnType<(typeof Transaction)['deserialize']>
+}) {
+ const {
+ challengeExpires,
+ chainId,
+ client,
+ details,
+ expectedFeeToken,
+ feePayer,
+ feePayerPolicy,
+ label,
+ serializedTransaction,
+ transaction,
+ } = parameters
+
+ if (!feePayer) {
+ const txHash = await sendTransaction(client, serializedTransaction)
+ return waitForSuccessfulReceipt(client, txHash)
+ }
+
+ if (!FeePayer.isTempoTransaction(serializedTransaction))
+ throw new BadRequestError({ reason: 'Only Tempo (0x76/0x78) transactions are supported' })
+ assertSenderSigned(transaction)
+
+ await call(client, {
+ ...transaction,
+ account: transaction.from,
+ calls: transaction.calls ?? [],
+ feePayerSignature: undefined,
+ } as never)
+
+ const sponsored = FeePayer.prepareSponsoredTransaction({
+ account: feePayer,
+ challengeExpires,
+ chainId,
+ details,
+ expectedFeeToken,
+ policy: feePayerPolicy,
+ transaction: {
+ ...transaction,
+ ...(expectedFeeToken ? { feeToken: transaction.feeToken ?? expectedFeeToken } : {}),
+ },
+ })
+ const serialized = (await signTransaction(client, sponsored as never)) as Hex
+ const receipt = await sendRawTransactionSync(client, {
+ serializedTransaction: serialized as Transaction.TransactionSerializedTempo,
+ })
+ if (receipt.status !== 'success')
+ throw new VerificationFailedError({
+ reason: `${label} precompile transaction reverted: ${receipt.transactionHash}`,
+ })
+ return receipt
+}
+
+export type BroadcastOpenTransactionResult = {
+ txHash: Hex
+ descriptor: ChannelDescriptor
+ state: ChannelState
+ expiringNonceHash: Hex
+ openDeposit: bigint
+}
+
+/** Broadcast and validate a client-signed TIP-1034 open transaction. */
+export async function broadcastOpenTransaction(parameters: {
+ challengeExpires?: string | undefined
+ chainId: number
+ client: TransactionClient
+ escrowContract: Address
+ expectedAuthorizedSigner: Address
+ expectedChannelId: Hex
+ expectedCurrency: Address
+ expectedExpiringNonceHash: Hex
+ expectedOperator: Address
+ expectedPayee: Address
+ expectedPayer: Address
+ feePayer?: Account | undefined
+ feePayerPolicy?: Partial | undefined
+ serializedTransaction: Hex
+ beforeBroadcast?:
+ | ((result: Omit) => Promise | void)
+ | undefined
+}): Promise {
+ if (parameters.feePayer && !FeePayer.isTempoTransaction(parameters.serializedTransaction))
+ throw new BadRequestError({ reason: 'Only Tempo (0x76/0x78) transactions are supported' })
+
+ const transaction = Transaction.deserialize(
+ parameters.serializedTransaction as Transaction.TransactionSerializedTempo,
+ )
+ const calls = transaction.calls
+ if (calls.length !== 1)
+ throw new VerificationFailedError({
+ reason: 'TIP-1034 open transaction must contain exactly one call',
+ })
+ const call = calls[0]!
+ if (!call.to || !isAddressEqual(call.to, parameters.escrowContract))
+ throw new VerificationFailedError({
+ reason: 'TIP-1034 open transaction targets the wrong address',
+ })
+ const payer = transaction.from ?? parameters.expectedPayer
+ const open = ChannelOps.parseOpenCall({
+ data: call.data!,
+ expected: {
+ payee: parameters.expectedPayee,
+ token: parameters.expectedCurrency,
+ operator: parameters.expectedOperator,
+ authorizedSigner: parameters.expectedAuthorizedSigner,
+ },
+ })
+ const descriptor = ChannelOps.descriptorFromOpen({
+ chainId: parameters.chainId,
+ escrow: parameters.escrowContract,
+ payer,
+ open,
+ expiringNonceHash: parameters.expectedExpiringNonceHash,
+ channelId: parameters.expectedChannelId,
+ })
+ const expiringNonceHash = ChannelUtils.computeExpiringNonceHash(
+ transaction as ChannelUtils.ExpiringNonceTransaction,
+ { sender: payer },
+ )
+ if (expiringNonceHash.toLowerCase() !== descriptor.expiringNonceHash.toLowerCase())
+ throw new VerificationFailedError({
+ reason: 'credential expiringNonceHash does not match transaction',
+ })
+ await parameters.beforeBroadcast?.({
+ descriptor,
+ expiringNonceHash,
+ openDeposit: open.deposit,
+ })
+ const receipt = await sendCredentialTransaction({
+ challengeExpires: parameters.challengeExpires,
+ chainId: parameters.chainId,
+ client: parameters.client,
+ details: {
+ channelId: parameters.expectedChannelId,
+ currency: parameters.expectedCurrency,
+ recipient: parameters.expectedPayee,
+ },
+ expectedFeeToken: parameters.expectedCurrency,
+ feePayer: parameters.feePayer,
+ feePayerPolicy: parameters.feePayerPolicy,
+ label: 'open',
+ serializedTransaction: parameters.serializedTransaction,
+ transaction,
+ })
+ const opened = getChannelEvent(receipt, 'ChannelOpened', parameters.expectedChannelId)
+ const emittedChannelId = opened.args.channelId as Hex
+ const emittedExpiringNonceHash = opened.args.expiringNonceHash as Hex
+ const emittedDeposit = uint96(opened.args.deposit as bigint)
+ if (emittedChannelId.toLowerCase() !== parameters.expectedChannelId.toLowerCase())
+ throw new VerificationFailedError({
+ reason: 'ChannelOpened channelId does not match credential',
+ })
+ if (emittedExpiringNonceHash.toLowerCase() !== descriptor.expiringNonceHash.toLowerCase())
+ throw new VerificationFailedError({
+ reason: 'ChannelOpened expiringNonceHash does not match descriptor',
+ })
+ if (emittedDeposit !== open.deposit)
+ throw new VerificationFailedError({ reason: 'ChannelOpened deposit does not match calldata' })
+ const confirmedChannelId = ChannelUtils.computeId({
+ ...descriptor,
+ chainId: parameters.chainId,
+ escrow: parameters.escrowContract,
+ })
+ if (confirmedChannelId.toLowerCase() !== emittedChannelId.toLowerCase())
+ throw new VerificationFailedError({
+ reason: 'descriptor does not match ChannelOpened channelId',
+ })
+ const chainChannel = await getChannel(parameters.client, descriptor, parameters.escrowContract)
+ const state = chainChannel.state
+ if (state.deposit !== emittedDeposit || state.settled !== 0n || state.closeRequestedAt !== 0)
+ throw new VerificationFailedError({
+ reason: 'on-chain channel state does not match open receipt',
+ })
+ return {
+ txHash: receipt.transactionHash,
+ descriptor,
+ state,
+ expiringNonceHash: emittedExpiringNonceHash,
+ openDeposit: open.deposit,
+ }
+}
+
+export type BroadcastTopUpTransactionResult = {
+ txHash: Hex
+ newDeposit: bigint
+ state: ChannelState
+}
+
+/** Broadcast and validate a client-signed TIP-1034 top-up transaction. */
+export async function broadcastTopUpTransaction(parameters: {
+ additionalDeposit: bigint
+ challengeExpires?: string | undefined
+ chainId: number
+ client: TransactionClient
+ descriptor: ChannelDescriptor
+ escrowContract: Address
+ expectedCurrency: Address
+ expectedChannelId: Hex
+ feePayer?: Account | undefined
+ feePayerPolicy?: Partial | undefined
+ serializedTransaction: Hex
+}): Promise {
+ if (parameters.feePayer && !FeePayer.isTempoTransaction(parameters.serializedTransaction))
+ throw new BadRequestError({ reason: 'Only Tempo (0x76/0x78) transactions are supported' })
+
+ const transaction = Transaction.deserialize(
+ parameters.serializedTransaction as Transaction.TransactionSerializedTempo,
+ )
+ const calls = transaction.calls
+ if (calls.length !== 1)
+ throw new VerificationFailedError({
+ reason: 'TIP-1034 topUp transaction must contain exactly one call',
+ })
+ const call = calls[0]!
+ if (!call.to || !isAddressEqual(call.to, parameters.escrowContract))
+ throw new VerificationFailedError({
+ reason: 'TIP-1034 topUp transaction targets the wrong address',
+ })
+ ChannelOps.parseTopUpCall({
+ data: call.data!,
+ expected: {
+ descriptor: parameters.descriptor,
+ additionalDeposit: parameters.additionalDeposit,
+ },
+ })
+ const receipt = await sendCredentialTransaction({
+ challengeExpires: parameters.challengeExpires,
+ chainId: parameters.chainId,
+ client: parameters.client,
+ details: {
+ additionalDeposit: parameters.additionalDeposit.toString(),
+ channelId: parameters.expectedChannelId,
+ currency: parameters.expectedCurrency,
+ },
+ expectedFeeToken: parameters.expectedCurrency,
+ feePayer: parameters.feePayer,
+ feePayerPolicy: parameters.feePayerPolicy,
+ label: 'topUp',
+ serializedTransaction: parameters.serializedTransaction,
+ transaction,
+ })
+ const toppedUp = getChannelEvent(receipt, 'TopUp', parameters.expectedChannelId)
+ const emittedChannelId = toppedUp.args.channelId as Hex
+ const newDeposit = uint96(toppedUp.args.newDeposit as bigint)
+ if (emittedChannelId.toLowerCase() !== parameters.expectedChannelId.toLowerCase())
+ throw new VerificationFailedError({ reason: 'TopUp channelId does not match credential' })
+ const state = await getChannelState(
+ parameters.client,
+ emittedChannelId,
+ parameters.escrowContract,
+ )
+ if (state.deposit !== newDeposit)
+ throw new VerificationFailedError({
+ reason: 'on-chain channel state does not match topUp receipt',
+ })
+ return { txHash: receipt.transactionHash, newDeposit, state }
+}
+
+function stateFromTuple(state: {
+ settled: bigint
+ deposit: bigint
+ closeRequestedAt: number
+}): ChannelState {
+ assertUint96(state.settled)
+ assertUint96(state.deposit)
+ return {
+ settled: state.settled,
+ deposit: state.deposit,
+ closeRequestedAt: state.closeRequestedAt,
+ }
+}
+
+function descriptorTuple(descriptor: ChannelDescriptor) {
+ return {
+ payer: descriptor.payer,
+ payee: descriptor.payee,
+ operator: descriptor.operator,
+ token: descriptor.token,
+ salt: descriptor.salt,
+ authorizedSigner: descriptor.authorizedSigner,
+ expiringNonceHash: descriptor.expiringNonceHash,
+ } as const
+}
+
+function assertFeePayerPolicy(
+ prepared: {
+ gas?: bigint | undefined
+ maxFeePerGas?: bigint | undefined
+ maxPriorityFeePerGas?: bigint | undefined
+ },
+ policy: Partial | undefined,
+) {
+ if (!policy) return
+ if (policy.maxGas !== undefined && (prepared.gas ?? 0n) > policy.maxGas)
+ throw new BadRequestError({ reason: 'fee-payer policy maxGas exceeded' })
+ if (policy.maxFeePerGas !== undefined && (prepared.maxFeePerGas ?? 0n) > policy.maxFeePerGas)
+ throw new BadRequestError({ reason: 'fee-payer policy maxFeePerGas exceeded' })
+ if (
+ policy.maxPriorityFeePerGas !== undefined &&
+ (prepared.maxPriorityFeePerGas ?? 0n) > policy.maxPriorityFeePerGas
+ )
+ throw new BadRequestError({ reason: 'fee-payer policy maxPriorityFeePerGas exceeded' })
+ if (
+ policy.maxTotalFee !== undefined &&
+ (prepared.gas ?? 0n) * (prepared.maxFeePerGas ?? 0n) > policy.maxTotalFee
+ )
+ throw new BadRequestError({ reason: 'fee-payer policy maxTotalFee exceeded' })
+}
+
+async function sendPrecompileTransaction(
+ client: Client,
+ to: Address,
+ data: Hex,
+ label: string,
+ options?: SendOptions,
+): Promise {
+ if (options?.feePayer) {
+ const account = options.account ?? client.account
+ if (!account) throw new Error(`Cannot ${label} precompile channel: no account available.`)
+ const feeToken =
+ options.feeToken ??
+ (await resolveFeeToken({
+ account: options.feePayer.address,
+ candidateTokens: options.candidateFeeTokens,
+ client,
+ }))
+ const prepared = await prepareTransactionRequest(client, {
+ account,
+ calls: [{ to, data }],
+ feePayer: true,
+ ...(feeToken ? { feeToken } : {}),
+ } as never)
+ assertFeePayerPolicy(prepared, options.feePayerPolicy)
+ const serialized = (await signTransaction(client, {
+ ...prepared,
+ account,
+ feePayer: options.feePayer,
+ } as never)) as Hex
+ const receipt = await sendRawTransactionSync(client, {
+ serializedTransaction: serialized as Transaction.TransactionSerializedTempo,
+ })
+ if (receipt.status !== 'success')
+ throw new VerificationFailedError({
+ reason: `${label} precompile transaction reverted: ${receipt.transactionHash}`,
+ })
+ return receipt.transactionHash
+ }
+
+ return sendViemTransaction(client, {
+ ...(options?.account ? { account: options.account } : {}),
+ to,
+ data,
+ ...(options?.feeToken ? { feeToken: options.feeToken } : {}),
+ } as never)
+}
diff --git a/src/tempo/precompile/Channel.test.ts b/src/tempo/precompile/Channel.test.ts
new file mode 100644
index 00000000..8251467c
--- /dev/null
+++ b/src/tempo/precompile/Channel.test.ts
@@ -0,0 +1,110 @@
+import { AbiParameters, Hash } from 'ox'
+import { describe, expect, test } from 'vp/test'
+
+import * as Channel from './Channel.js'
+import { tip20ChannelEscrow } from './Constants.js'
+
+const descriptor = {
+ payer: '0x1111111111111111111111111111111111111111',
+ payee: '0x2222222222222222222222222222222222222222',
+ operator: '0x3333333333333333333333333333333333333333',
+ token: '0x4444444444444444444444444444444444444444',
+ salt: '0x0000000000000000000000000000000000000000000000000000000000000001',
+ authorizedSigner: '0x5555555555555555555555555555555555555555',
+ expiringNonceHash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+} as const satisfies Channel.ChannelDescriptor
+
+const chainId = 42431
+
+describe('precompile Channel.computeId', () => {
+ test('returns deterministic 32-byte hash for fixed inputs', () => {
+ const id = Channel.computeId({ ...descriptor, chainId })
+ expect(Channel.computeId({ ...descriptor, chainId })).toBe(id)
+ expect(id).toMatch(/^0x[0-9a-f]{64}$/)
+ })
+
+ test('matches manual keccak256(abi.encode(...))', () => {
+ const encoded = AbiParameters.encode(
+ AbiParameters.from([
+ 'address payer',
+ 'address payee',
+ 'address operator',
+ 'address token',
+ 'bytes32 salt',
+ 'address authorizedSigner',
+ 'bytes32 expiringNonceHash',
+ 'address escrow',
+ 'uint256 chainId',
+ ]),
+ [
+ descriptor.payer,
+ descriptor.payee,
+ descriptor.operator,
+ descriptor.token,
+ descriptor.salt,
+ descriptor.authorizedSigner,
+ descriptor.expiringNonceHash,
+ tip20ChannelEscrow,
+ BigInt(chainId),
+ ],
+ )
+ expect(Channel.computeId({ ...descriptor, chainId })).toBe(Hash.keccak256(encoded))
+ })
+
+ test.each([
+ ['payer', '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
+ ['payee', '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'],
+ ['operator', '0xcccccccccccccccccccccccccccccccccccccccc'],
+ ['token', '0xdddddddddddddddddddddddddddddddddddddddd'],
+ ['salt', '0x0000000000000000000000000000000000000000000000000000000000000002'],
+ ['authorizedSigner', '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'],
+ ['expiringNonceHash', '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'],
+ ] as const)('changes when %s changes', (key, value) => {
+ expect(Channel.computeId({ ...descriptor, [key]: value, chainId })).not.toBe(
+ Channel.computeId({ ...descriptor, chainId }),
+ )
+ })
+
+ test('changes when escrow or chainId changes', () => {
+ expect(
+ Channel.computeId({
+ ...descriptor,
+ chainId,
+ escrow: '0xffffffffffffffffffffffffffffffffffffffff',
+ }),
+ ).not.toBe(Channel.computeId({ ...descriptor, chainId }))
+ expect(Channel.computeId({ ...descriptor, chainId: 1 })).not.toBe(
+ Channel.computeId({ ...descriptor, chainId }),
+ )
+ })
+
+ test('encodes chainId as uint256', () => {
+ const largeChainId = 2 ** 32
+ const id = Channel.computeId({ ...descriptor, chainId: largeChainId })
+ const encoded = AbiParameters.encode(
+ AbiParameters.from([
+ 'address payer',
+ 'address payee',
+ 'address operator',
+ 'address token',
+ 'bytes32 salt',
+ 'address authorizedSigner',
+ 'bytes32 expiringNonceHash',
+ 'address escrow',
+ 'uint256 chainId',
+ ]),
+ [
+ descriptor.payer,
+ descriptor.payee,
+ descriptor.operator,
+ descriptor.token,
+ descriptor.salt,
+ descriptor.authorizedSigner,
+ descriptor.expiringNonceHash,
+ tip20ChannelEscrow,
+ BigInt(largeChainId),
+ ],
+ )
+ expect(id).toBe(Hash.keccak256(encoded))
+ })
+})
diff --git a/src/tempo/precompile/Channel.ts b/src/tempo/precompile/Channel.ts
new file mode 100644
index 00000000..114dde02
--- /dev/null
+++ b/src/tempo/precompile/Channel.ts
@@ -0,0 +1,76 @@
+import { AbiParameters, Hash, Hex } from 'ox'
+import { TxEnvelopeTempo } from 'ox/tempo'
+import type { Account, Address, Hex as viem_Hex } from 'viem'
+import { type z_TransactionRequestTempo, type z_TransactionSerializableTempo } from 'viem/tempo'
+
+import { tip20ChannelEscrow } from './Constants.js'
+import type { ChannelDescriptor } from './Types.js'
+
+export type { ChannelDescriptor } from './Types.js'
+
+export type ExpiringNonceTransaction = (
+ | z_TransactionSerializableTempo
+ | z_TransactionRequestTempo
+) & {
+ feePayer?: Account | true | undefined
+}
+
+/** Computes the TIP-1034 channel ID for a precompile channel descriptor. */
+export function computeId(parameters: computeId.Parameters): viem_Hex {
+ const encoded = AbiParameters.encode(
+ AbiParameters.from([
+ 'address payer',
+ 'address payee',
+ 'address operator',
+ 'address token',
+ 'bytes32 salt',
+ 'address authorizedSigner',
+ 'bytes32 expiringNonceHash',
+ 'address escrow',
+ 'uint256 chainId',
+ ]),
+ [
+ parameters.payer,
+ parameters.payee,
+ parameters.operator,
+ parameters.token,
+ parameters.salt,
+ parameters.authorizedSigner,
+ parameters.expiringNonceHash,
+ parameters.escrow ?? tip20ChannelEscrow,
+ BigInt(parameters.chainId),
+ ],
+ )
+ return Hash.keccak256(encoded)
+}
+
+export declare namespace computeId {
+ type Parameters = ChannelDescriptor & {
+ chainId: number
+ escrow?: Address | undefined
+ }
+}
+
+/**
+ * Computes the TIP-1034 `expiringNonceHash` for a channel-opening Tempo transaction.
+ *
+ * This uses the same Tempo transaction signing payload that the node uses for the
+ * enclosing transaction context: `keccak256(abi.encodePacked(encodeForSigning, sender))`.
+ */
+export function computeExpiringNonceHash(
+ transaction: ExpiringNonceTransaction,
+ parameters: { sender: Address },
+): viem_Hex {
+ const transaction_ = transaction as ExpiringNonceTransaction & {
+ feePayerSignature?: unknown
+ }
+ const envelope = TxEnvelopeTempo.from({
+ ...transaction_,
+ feeToken:
+ transaction_.feePayer === true && !transaction_.feePayerSignature
+ ? undefined
+ : transaction_.feeToken,
+ type: 'tempo',
+ } as TxEnvelopeTempo.Input)
+ return Hash.keccak256(Hex.concat(TxEnvelopeTempo.encodeForSigning(envelope), parameters.sender))
+}
diff --git a/src/tempo/precompile/Constants.ts b/src/tempo/precompile/Constants.ts
new file mode 100644
index 00000000..6503c094
--- /dev/null
+++ b/src/tempo/precompile/Constants.ts
@@ -0,0 +1,2 @@
+/** Canonical TIP-1034 TIP-20 Channel Escrow precompile address. */
+export const tip20ChannelEscrow = '0x4d50500000000000000000000000000000000000'
diff --git a/src/tempo/precompile/Types.test.ts b/src/tempo/precompile/Types.test.ts
new file mode 100644
index 00000000..1ae6885c
--- /dev/null
+++ b/src/tempo/precompile/Types.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from 'vp/test'
+
+import * as Types from './Types.js'
+
+const maxUint96 = (1n << 96n) - 1n
+
+describe('precompile Uint96', () => {
+ test('accepts lower and upper bounds', () => {
+ expect(Types.uint96(0n)).toBe(0n)
+ expect(Types.uint96(maxUint96)).toBe(maxUint96)
+ expect(Types.isUint96(0n)).toBe(true)
+ expect(Types.isUint96(maxUint96)).toBe(true)
+ })
+
+ test('rejects values outside uint96 bounds', () => {
+ expect(() => Types.uint96(-1n)).toThrow('outside uint96 bounds')
+ expect(() => Types.uint96(maxUint96 + 1n)).toThrow('outside uint96 bounds')
+ expect(Types.isUint96(-1n)).toBe(false)
+ expect(Types.isUint96(maxUint96 + 1n)).toBe(false)
+ })
+
+ test('assertUint96 validates valid values and throws for invalid values', () => {
+ const amount: bigint = 1n
+ Types.assertUint96(amount)
+ expect(amount).toBe(1n)
+ expect(() => Types.assertUint96(maxUint96 + 1n)).toThrow('outside uint96 bounds')
+ })
+})
diff --git a/src/tempo/precompile/Types.ts b/src/tempo/precompile/Types.ts
new file mode 100644
index 00000000..320bf5a7
--- /dev/null
+++ b/src/tempo/precompile/Types.ts
@@ -0,0 +1,83 @@
+import type { Address, Hex } from 'viem'
+
+const maxUint96 = (1n << 96n) - 1n
+
+/** Amount encoded by TIP20EscrowChannel as a `uint96` on-chain value. */
+export type Uint96 = bigint
+
+/** Returns whether a bigint can be encoded as a TIP20EscrowChannel `uint96` amount. */
+export function isUint96(value: bigint): value is Uint96 {
+ return value >= 0n && value <= maxUint96
+}
+
+/** Converts a bigint into a TIP20EscrowChannel `uint96` amount after validating bounds. */
+export function uint96(value: bigint): Uint96 {
+ assertUint96(value)
+ return value
+}
+
+/** Asserts that a bigint can be encoded as a TIP20EscrowChannel `uint96` amount. */
+export function assertUint96(value: bigint): void {
+ if (!isUint96(value)) throw new Error(`Value ${value} is outside uint96 bounds.`)
+}
+
+export type ChannelDescriptor = {
+ payer: Address
+ payee: Address
+ operator: Address
+ token: Address
+ salt: Hex
+ authorizedSigner: Address
+ expiringNonceHash: Hex
+}
+
+/**
+ * Voucher for cumulative payment.
+ * Cumulative monotonicity prevents replay attacks.
+ */
+export type Voucher = {
+ channelId: Hex
+ cumulativeAmount: bigint
+}
+
+/**
+ * Signed voucher with EIP-712 signature.
+ */
+export type SignedVoucher = Voucher & { signature: Hex }
+
+/**
+ * TIP20EscrowChannel precompile session credential payload (discriminated union).
+ */
+export type SessionCredentialPayload =
+ | {
+ action: 'open'
+ type: 'transaction'
+ channelId: Hex
+ transaction: Hex
+ signature: Hex
+ descriptor: ChannelDescriptor
+ cumulativeAmount: string
+ authorizedSigner?: Address | undefined
+ }
+ | {
+ action: 'topUp'
+ type: 'transaction'
+ channelId: Hex
+ transaction: Hex
+ descriptor: ChannelDescriptor
+ additionalDeposit: string
+ }
+ | {
+ action: 'voucher'
+ channelId: Hex
+ descriptor: ChannelDescriptor
+ cumulativeAmount: string
+ signature: Hex
+ }
+ | {
+ action: 'close'
+ channelId: Hex
+ descriptor: ChannelDescriptor
+ cumulativeAmount: string
+ signature: Hex
+ }
diff --git a/src/tempo/precompile/Voucher.test.ts b/src/tempo/precompile/Voucher.test.ts
new file mode 100644
index 00000000..eabf5cd7
--- /dev/null
+++ b/src/tempo/precompile/Voucher.test.ts
@@ -0,0 +1,274 @@
+import { P256, Secp256k1 } from 'ox'
+import { SignatureEnvelope } from 'ox/tempo'
+import { createClient, hashTypedData, http } from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { signTypedData } from 'viem/actions'
+import { Account as TempoAccount } from 'viem/tempo'
+import { describe, expect, test } from 'vp/test'
+
+import { uint96 } from './Types.js'
+import {
+ getVoucherDomain,
+ parseVoucherFromPayload,
+ signVoucher,
+ voucherTypes,
+ verifyVoucher,
+} from './Voucher.js'
+
+const account = privateKeyToAccount(
+ '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
+)
+const escrowContract = '0x1234567890abcdef1234567890abcdef12345678' as const
+const chainId = 42431
+
+const client = createClient({
+ account,
+ transport: http('http://127.0.0.1'), // only used for local signTypedData
+})
+
+const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as const
+const cumulativeAmount = uint96(1_000_000n)
+
+describe('Precompile Voucher', () => {
+ test('sign and verify round-trip', async () => {
+ const signature = await signVoucher(
+ client,
+ account,
+ { channelId, cumulativeAmount },
+ escrowContract,
+ chainId,
+ )
+ expect(signature).toMatch(/^0x/)
+ expect(signature.length).toBe(132)
+
+ const isValid = verifyVoucher(
+ escrowContract,
+ chainId,
+ { channelId, cumulativeAmount, signature },
+ account.address,
+ )
+ expect(isValid).toBe(true)
+ })
+
+ test('verify rejects wrong signer', async () => {
+ const signature = await signVoucher(
+ client,
+ account,
+ { channelId, cumulativeAmount },
+ escrowContract,
+ chainId,
+ )
+
+ const wrongAddress = '0x0000000000000000000000000000000000000001' as const
+ const isValid = verifyVoucher(
+ escrowContract,
+ chainId,
+ { channelId, cumulativeAmount, signature },
+ wrongAddress,
+ )
+ expect(isValid).toBe(false)
+ })
+
+ test('verify rejects tampered amount', async () => {
+ const signature = await signVoucher(
+ client,
+ account,
+ { channelId, cumulativeAmount },
+ escrowContract,
+ chainId,
+ )
+
+ const isValid = verifyVoucher(
+ escrowContract,
+ chainId,
+ { channelId, cumulativeAmount: uint96(9_999_999n), signature },
+ account.address,
+ )
+ expect(isValid).toBe(false)
+ })
+
+ test('verify rejects tampered channelId', async () => {
+ const signature = await signVoucher(
+ client,
+ account,
+ { channelId, cumulativeAmount },
+ escrowContract,
+ chainId,
+ )
+
+ const wrongChannelId =
+ '0x0000000000000000000000000000000000000000000000000000000000000099' as const
+ const isValid = verifyVoucher(
+ escrowContract,
+ chainId,
+ { channelId: wrongChannelId, cumulativeAmount, signature },
+ account.address,
+ )
+ expect(isValid).toBe(false)
+ })
+
+ test('verify rejects wrong chain ID', async () => {
+ const signature = await signVoucher(
+ client,
+ account,
+ { channelId, cumulativeAmount },
+ escrowContract,
+ chainId,
+ )
+
+ const isValid = verifyVoucher(
+ escrowContract,
+ 99999,
+ { channelId, cumulativeAmount, signature },
+ account.address,
+ )
+ expect(isValid).toBe(false)
+ })
+
+ test('verify returns false for invalid signature', () => {
+ const isValid = verifyVoucher(
+ escrowContract,
+ chainId,
+ { channelId, cumulativeAmount, signature: '0xdeadbeef' },
+ account.address,
+ )
+ expect(isValid).toBe(false)
+ })
+
+ test('parseVoucherFromPayload', () => {
+ const signature =
+ '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab' as const
+ const voucher = parseVoucherFromPayload(channelId, '5000000', signature)
+ expect(voucher.channelId).toBe(channelId)
+ expect(voucher.cumulativeAmount).toBe(5_000_000n)
+ expect(voucher.signature).toBe(signature)
+ })
+
+ test('parseVoucherFromPayload with zero amount', () => {
+ const signature =
+ '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab' as const
+ const voucher = parseVoucherFromPayload(channelId, '0', signature)
+ expect(voucher.cumulativeAmount).toBe(0n)
+ })
+
+ test('parseVoucherFromPayload rejects amounts outside uint96 bounds', () => {
+ const signature = '0xdeadbeef' as const
+ expect(() => parseVoucherFromPayload(channelId, '-1', signature)).toThrow(
+ 'outside uint96 bounds',
+ )
+ expect(() => parseVoucherFromPayload(channelId, (1n << 96n).toString(), signature)).toThrow(
+ 'outside uint96 bounds',
+ )
+ })
+
+ test('verify rejects wrong escrow contract', async () => {
+ const signature = await signVoucher(
+ client,
+ account,
+ { channelId, cumulativeAmount },
+ escrowContract,
+ chainId,
+ )
+
+ const wrongEscrow = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as const
+ const isValid = verifyVoucher(
+ wrongEscrow,
+ chainId,
+ { channelId, cumulativeAmount, signature },
+ account.address,
+ )
+ expect(isValid).toBe(false)
+ })
+
+ test('sign and verify round-trip with zero amount', async () => {
+ const zeroAmount = uint96(0n)
+ const signature = await signVoucher(
+ client,
+ account,
+ { channelId, cumulativeAmount: zeroAmount },
+ escrowContract,
+ chainId,
+ )
+ expect(signature).toMatch(/^0x/)
+
+ const isValid = verifyVoucher(
+ escrowContract,
+ chainId,
+ { channelId, cumulativeAmount: zeroAmount, signature },
+ account.address,
+ )
+ expect(isValid).toBe(true)
+ })
+
+ test('verify rejects direct keychain wrapper signatures', async () => {
+ const signature = await signTypedData(client, {
+ account,
+ domain: getVoucherDomain(escrowContract, chainId),
+ types: voucherTypes,
+ primaryType: 'Voucher',
+ message: { channelId, cumulativeAmount },
+ })
+ const envelope = SignatureEnvelope.from(signature as SignatureEnvelope.Serialized)
+ const wrapped = SignatureEnvelope.serialize(
+ {
+ inner: envelope,
+ type: 'keychain',
+ userAddress: account.address,
+ version: 'v1',
+ },
+ { magic: true },
+ )
+
+ expect(
+ verifyVoucher(
+ escrowContract,
+ chainId,
+ { channelId, cumulativeAmount, signature: wrapped },
+ account.address,
+ ),
+ ).toBe(false)
+ })
+
+ test('sign rejects p256 keychain access-key voucher delegation explicitly', async () => {
+ const rootAccount = TempoAccount.fromSecp256k1(Secp256k1.randomPrivateKey())
+ const accessKey = TempoAccount.fromP256(P256.randomPrivateKey(), {
+ access: rootAccount,
+ })
+ const accessKeyClient = createClient({
+ account: accessKey,
+ transport: http('http://127.0.0.1'),
+ })
+
+ await expect(
+ signVoucher(
+ accessKeyClient,
+ accessKey,
+ { channelId, cumulativeAmount },
+ escrowContract,
+ chainId,
+ accessKey.accessKeyAddress,
+ ),
+ ).rejects.toThrow('TIP-1034 voucher signing only supports secp256k1 keychain access keys.')
+ })
+
+ test('domain and type match TIP-1034', () => {
+ expect(getVoucherDomain(escrowContract, chainId)).toEqual({
+ name: 'TIP20 Channel Escrow',
+ version: '1',
+ chainId,
+ verifyingContract: escrowContract,
+ })
+ expect(voucherTypes.Voucher).toEqual([
+ { name: 'channelId', type: 'bytes32' },
+ { name: 'cumulativeAmount', type: 'uint96' },
+ ])
+ expect(
+ hashTypedData({
+ domain: getVoucherDomain(escrowContract, chainId),
+ types: voucherTypes,
+ primaryType: 'Voucher',
+ message: { channelId, cumulativeAmount },
+ }),
+ ).toMatch(/^0x[0-9a-f]{64}$/)
+ })
+})
diff --git a/src/tempo/precompile/Voucher.ts b/src/tempo/precompile/Voucher.ts
new file mode 100644
index 00000000..e29b8905
--- /dev/null
+++ b/src/tempo/precompile/Voucher.ts
@@ -0,0 +1,128 @@
+import { Signature } from 'ox'
+import { SignatureEnvelope } from 'ox/tempo'
+import type { Account, Address, Client, Hex } from 'viem'
+import { hashTypedData } from 'viem'
+import { signTypedData } from 'viem/actions'
+
+import * as TempoAddress from '../internal/address.js'
+import type { Voucher, SignedVoucher } from './Types.js'
+import { uint96 } from './Types.js'
+
+/** Must match the on-chain TIP20 channel escrow DOMAIN_SEPARATOR name. */
+const DOMAIN_NAME = 'TIP20 Channel Escrow'
+/** Must match the on-chain TIP20 channel escrow DOMAIN_SEPARATOR version. */
+const DOMAIN_VERSION = '1'
+
+/**
+ * EIP-712 domain for voucher signing.
+ */
+export function getVoucherDomain(escrowContract: Address, chainId: number) {
+ return {
+ name: DOMAIN_NAME,
+ version: DOMAIN_VERSION,
+ chainId,
+ verifyingContract: escrowContract,
+ } as const
+}
+
+/**
+ * EIP-712 types for voucher signing.
+ * Matches @tempo/stream-channels/voucher and on-chain VOUCHER_TYPEHASH.
+ */
+export const voucherTypes = {
+ Voucher: [
+ { name: 'channelId', type: 'bytes32' },
+ { name: 'cumulativeAmount', type: 'uint96' },
+ ],
+} as const
+
+/**
+ * Sign a voucher with an account.
+ */
+export async function signVoucher(
+ client: Client,
+ account: Account,
+ voucher: Voucher,
+ verifyingContract: Address,
+ chainId: number,
+ authorizedSigner?: Address | undefined,
+): Promise {
+ const signature = await signTypedData(client, {
+ account,
+ domain: getVoucherDomain(verifyingContract, chainId),
+ types: voucherTypes,
+ primaryType: 'Voucher',
+ message: voucher,
+ })
+
+ // When a separate authorizedSigner is used (e.g. access key), unwrap the
+ // keychain envelope — the escrow contract verifies raw ECDSA signatures
+ // against authorizedSigner, not keychain-wrapped ones.
+ // TODO: when TIP-1020 is implemented, we can remove this.
+ if (authorizedSigner) {
+ const envelope = (() => {
+ try {
+ return SignatureEnvelope.from(signature as SignatureEnvelope.Serialized)
+ } catch {
+ return undefined
+ }
+ })()
+ if (envelope?.type === 'keychain' && envelope.inner.type === 'secp256k1')
+ return Signature.toHex(envelope.inner.signature)
+ if (envelope?.type === 'keychain')
+ throw new Error('TIP-1034 voucher signing only supports secp256k1 keychain access keys.')
+ }
+
+ return signature
+}
+
+/**
+ * Verify a voucher signature matches the expected signer.
+ *
+ * Only accepts raw secp256k1 signatures — the escrow contract verifies
+ * via ecrecover. Keychain, p256, and webAuthn signatures are rejected.
+ */
+export function verifyVoucher(
+ escrowContract: Address,
+ chainId: number,
+ voucher: SignedVoucher,
+ expectedSigner: Address,
+): boolean {
+ try {
+ const envelope = SignatureEnvelope.from(voucher.signature as SignatureEnvelope.Serialized)
+
+ // Reject keychain signatures — the escrow contract verifies raw ECDSA
+ // signatures against authorizedSigner, not keychain-wrapped ones.
+ if (envelope.type === 'keychain') return false
+
+ const payload = hashTypedData({
+ domain: getVoucherDomain(escrowContract, chainId),
+ types: voucherTypes,
+ primaryType: 'Voucher',
+ message: {
+ channelId: voucher.channelId,
+ cumulativeAmount: voucher.cumulativeAmount,
+ },
+ })
+ const signer = SignatureEnvelope.extractAddress({ payload, signature: envelope })
+ const valid = SignatureEnvelope.verify(envelope, { address: signer, payload })
+ return valid && TempoAddress.isEqual(signer, expectedSigner)
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Parse a voucher from credential payload.
+ */
+export function parseVoucherFromPayload(
+ channelId: Hex,
+ cumulativeAmount: string,
+ signature: Hex,
+): SignedVoucher {
+ return {
+ channelId,
+ cumulativeAmount: uint96(BigInt(cumulativeAmount)),
+ signature,
+ }
+}
diff --git a/src/tempo/precompile/client/ChannelOps.test.ts b/src/tempo/precompile/client/ChannelOps.test.ts
new file mode 100644
index 00000000..02177f07
--- /dev/null
+++ b/src/tempo/precompile/client/ChannelOps.test.ts
@@ -0,0 +1,115 @@
+import { Hex } from 'ox'
+import { type Address, createClient, custom, zeroAddress } from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { describe, expect, test } from 'vp/test'
+
+import * as Channel from '../Channel.js'
+import { tip20ChannelEscrow } from '../Constants.js'
+import * as Types from '../Types.js'
+import * as Voucher from '../Voucher.js'
+import * as ChannelOps from './ChannelOps.js'
+
+const account = privateKeyToAccount(
+ '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
+)
+const client = createClient({
+ account,
+ transport: custom({
+ async request() {
+ throw new Error('unexpected rpc request')
+ },
+ }),
+})
+const chainId = 42431
+
+const descriptor = {
+ payer: account.address,
+ payee: '0x0000000000000000000000000000000000000002' as Address,
+ operator: '0x0000000000000000000000000000000000000000' as Address,
+ token: '0x0000000000000000000000000000000000000003' as Address,
+ salt: `0x${'11'.repeat(32)}` as Hex.Hex,
+ authorizedSigner: account.address,
+ expiringNonceHash: `0x${'22'.repeat(32)}` as Hex.Hex,
+} satisfies Channel.ChannelDescriptor
+
+const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
+
+describe('precompile client ChannelOps credential builders', () => {
+ test('creates a verifiable voucher credential for an existing precompile channel', async () => {
+ const cumulativeAmount = Types.uint96(250n)
+ const payload = await ChannelOps.createVoucherPayload(
+ client,
+ account,
+ descriptor,
+ cumulativeAmount,
+ chainId,
+ )
+ if (payload.action !== 'voucher') throw new Error('expected voucher payload')
+
+ expect(payload.channelId).toBe(channelId)
+ expect(payload.descriptor).toEqual(descriptor)
+ expect(payload.cumulativeAmount).toBe('250')
+ expect(
+ Voucher.verifyVoucher(
+ tip20ChannelEscrow,
+ chainId,
+ { channelId, cumulativeAmount, signature: payload.signature },
+ descriptor.authorizedSigner,
+ ),
+ ).toBe(true)
+ })
+
+ test('uses the payer as voucher signer when descriptor authorizedSigner is zero', async () => {
+ const zeroSignerDescriptor = {
+ ...descriptor,
+ authorizedSigner: zeroAddress,
+ }
+ const zeroSignerChannelId = Channel.computeId({
+ ...zeroSignerDescriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ })
+ const cumulativeAmount = Types.uint96(275n)
+ const payload = await ChannelOps.createVoucherPayload(
+ client,
+ account,
+ zeroSignerDescriptor,
+ cumulativeAmount,
+ chainId,
+ )
+ if (payload.action !== 'voucher') throw new Error('expected voucher payload')
+
+ expect(payload.channelId).toBe(zeroSignerChannelId)
+ expect(
+ Voucher.verifyVoucher(
+ tip20ChannelEscrow,
+ chainId,
+ { channelId: zeroSignerChannelId, cumulativeAmount, signature: payload.signature },
+ descriptor.payer,
+ ),
+ ).toBe(true)
+ })
+
+ test('creates a close credential with a verifiable voucher signature', async () => {
+ const cumulativeAmount = Types.uint96(300n)
+ const payload = await ChannelOps.createClosePayload(
+ client,
+ account,
+ descriptor,
+ cumulativeAmount,
+ chainId,
+ )
+ if (payload.action !== 'close') throw new Error('expected close payload')
+
+ expect(payload.channelId).toBe(channelId)
+ expect(payload.cumulativeAmount).toBe('300')
+ expect(
+ Voucher.verifyVoucher(
+ tip20ChannelEscrow,
+ chainId,
+ { channelId, cumulativeAmount, signature: payload.signature },
+ descriptor.authorizedSigner,
+ ),
+ ).toBe(true)
+ })
+})
diff --git a/src/tempo/precompile/client/ChannelOps.ts b/src/tempo/precompile/client/ChannelOps.ts
new file mode 100644
index 00000000..25c1af7e
--- /dev/null
+++ b/src/tempo/precompile/client/ChannelOps.ts
@@ -0,0 +1,236 @@
+/**
+ * Shared client-side precompile channel operations.
+ *
+ * Provides the low-level helpers that both `precompile.session()` and
+ * `precompile.sessionManager()` rely on: channel ID computation,
+ * transaction-bound descriptor construction, on-chain open/top-up payload
+ * construction, voucher/close payload serialization, and transaction signing.
+ */
+import { Hex } from 'ox'
+import { encodeFunctionData, zeroAddress, type Account, type Address, type Client } from 'viem'
+import { prepareTransactionRequest, signTransaction } from 'viem/actions'
+
+import type { Challenge } from '../../../Challenge.js'
+import * as Credential from '../../../Credential.js'
+import * as Channel from '../Channel.js'
+import { tip20ChannelEscrow } from '../Constants.js'
+import { escrowAbi } from '../escrow.abi.js'
+import type { SessionCredentialPayload } from '../Types.js'
+import { uint96 } from '../Types.js'
+import * as Voucher from '../Voucher.js'
+
+export type ChannelEntry = {
+ channelId: Hex.Hex
+ cumulativeAmount: bigint
+ descriptor: Channel.ChannelDescriptor
+ escrow: Address
+ chainId: number
+ opened: boolean
+}
+
+function voucherAuthorizedSigner(address: Address): Address | undefined {
+ return address.toLowerCase() === zeroAddress ? undefined : address
+}
+
+export function resolveEscrow(
+ challenge: {
+ request: {
+ methodDetails?: { escrow?: string | undefined; escrowContract?: string | undefined }
+ }
+ },
+ escrowOverride?: Address | undefined,
+): Address {
+ const methodDetails = challenge.request.methodDetails
+ const challengeEscrow = (methodDetails?.escrowContract ?? methodDetails?.escrow) as
+ | Address
+ | undefined
+ return escrowOverride ?? challengeEscrow ?? tip20ChannelEscrow
+}
+
+export function serializeCredential(
+ challenge: Challenge,
+ payload: SessionCredentialPayload,
+ chainId: number,
+ account: Account,
+): string {
+ return Credential.serialize({
+ challenge,
+ payload,
+ source: `did:pkh:eip155:${chainId}:${account.address}`,
+ })
+}
+
+export function isSameAddress(a: Address, b: Address): boolean {
+ return a.toLowerCase() === b.toLowerCase()
+}
+
+/** Signs and creates a TIP-1034 voucher credential payload for an existing channel. */
+export async function createVoucherPayload(
+ client: Client,
+ account: Account,
+ descriptor: Channel.ChannelDescriptor,
+ cumulativeAmount: bigint,
+ chainId: number,
+): Promise {
+ const channelId = Channel.computeId({
+ ...descriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ })
+ const amount = uint96(cumulativeAmount)
+ const signature = await Voucher.signVoucher(
+ client,
+ account,
+ { channelId, cumulativeAmount: amount },
+ tip20ChannelEscrow,
+ chainId,
+ voucherAuthorizedSigner(descriptor.authorizedSigner),
+ )
+
+ return {
+ action: 'voucher',
+ channelId,
+ descriptor,
+ cumulativeAmount: amount.toString(),
+ signature,
+ }
+}
+
+/** Signs and creates a TIP-1034 close credential payload for an existing channel. */
+export async function createClosePayload(
+ client: Client,
+ account: Account,
+ descriptor: Channel.ChannelDescriptor,
+ cumulativeAmount: bigint,
+ chainId: number,
+): Promise {
+ const voucher = await createVoucherPayload(client, account, descriptor, cumulativeAmount, chainId)
+ if (voucher.action !== 'voucher') throw new Error('expected voucher payload')
+ return {
+ action: 'close',
+ channelId: voucher.channelId,
+ descriptor,
+ cumulativeAmount: voucher.cumulativeAmount,
+ signature: voucher.signature,
+ }
+}
+
+/**
+ * Prepares, signs, and creates a TIP-1034 open credential payload.
+ */
+export async function createOpenPayload(
+ client: Client,
+ account: Account,
+ parameters: {
+ authorizedSigner?: Address | undefined
+ chainId: number
+ deposit: bigint
+ feePayer?: boolean | undefined
+ initialAmount: bigint
+ operator?: Address | undefined
+ payee: Address
+ token: Address
+ },
+): Promise {
+ const authorizedSigner =
+ parameters.authorizedSigner ??
+ (account as unknown as { accessKeyAddress?: Address }).accessKeyAddress ??
+ account.address
+ const operator = parameters.operator ?? '0x0000000000000000000000000000000000000000'
+ const salt = Hex.random(32)
+
+ const deposit = uint96(parameters.deposit)
+ const initialAmount = uint96(parameters.initialAmount)
+ const openData = encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'open',
+ args: [parameters.payee, operator, parameters.token, deposit, salt, authorizedSigner],
+ })
+ const prepared = await prepareTransactionRequest(client, {
+ account,
+ calls: [{ to: tip20ChannelEscrow, data: openData }],
+ ...(parameters.feePayer ? { feePayer: true } : {}),
+ feeToken: parameters.token,
+ } as never)
+
+ const expiringNonceHash = Channel.computeExpiringNonceHash(
+ prepared as Channel.ExpiringNonceTransaction,
+ { sender: account.address },
+ )
+ const descriptor = {
+ authorizedSigner,
+ expiringNonceHash,
+ operator,
+ payee: parameters.payee,
+ payer: account.address,
+ salt,
+ token: parameters.token,
+ } satisfies Channel.ChannelDescriptor
+ const channelId = Channel.computeId({
+ ...descriptor,
+ chainId: parameters.chainId,
+ escrow: tip20ChannelEscrow,
+ })
+ const signature = await Voucher.signVoucher(
+ client,
+ account,
+ { channelId, cumulativeAmount: initialAmount },
+ tip20ChannelEscrow,
+ parameters.chainId,
+ voucherAuthorizedSigner(authorizedSigner),
+ )
+ const transaction = (await signTransaction(client, prepared as never)) as Hex.Hex
+
+ return {
+ action: 'open',
+ type: 'transaction',
+ channelId,
+ transaction,
+ signature,
+ descriptor,
+ cumulativeAmount: initialAmount.toString(),
+ authorizedSigner: descriptor.authorizedSigner,
+ }
+}
+
+/** Prepares, signs, and creates a TIP-1034 top-up credential payload. */
+export async function createTopUpPayload(
+ client: Client,
+ account: Account,
+ descriptor: Channel.ChannelDescriptor,
+ additionalDeposit: bigint,
+ chainId: number,
+ feePayer?: boolean | undefined,
+): Promise {
+ const channelId = Channel.computeId({
+ ...descriptor,
+ chainId,
+ escrow: tip20ChannelEscrow,
+ })
+ const deposit = uint96(additionalDeposit)
+ const prepared = await prepareTransactionRequest(client, {
+ account,
+ calls: [
+ {
+ to: tip20ChannelEscrow,
+ data: encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'topUp',
+ args: [descriptor, deposit],
+ }),
+ },
+ ],
+ ...(feePayer ? { feePayer: true } : {}),
+ feeToken: descriptor.token,
+ } as never)
+ const transaction = (await signTransaction(client, prepared as never)) as Hex.Hex
+
+ return {
+ action: 'topUp',
+ type: 'transaction',
+ channelId,
+ transaction,
+ descriptor,
+ additionalDeposit: deposit.toString(),
+ }
+}
diff --git a/src/tempo/precompile/client/Session.test.ts b/src/tempo/precompile/client/Session.test.ts
new file mode 100644
index 00000000..11dd7f9b
--- /dev/null
+++ b/src/tempo/precompile/client/Session.test.ts
@@ -0,0 +1,388 @@
+import { type Address, createClient, custom, decodeFunctionData, encodeFunctionResult } from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { Transaction } from 'viem/tempo'
+import { describe, expect, test } from 'vp/test'
+
+import type { Challenge } from '../../../Challenge.js'
+import * as Credential from '../../../Credential.js'
+import * as Channel from '../Channel.js'
+import { tip20ChannelEscrow } from '../Constants.js'
+import { escrowAbi } from '../escrow.abi.js'
+import * as Types from '../Types.js'
+import * as Voucher from '../Voucher.js'
+import { session } from './Session.js'
+
+const account = privateKeyToAccount(
+ '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
+)
+const chainId = 42431
+const client = createClient({
+ account,
+ chain: { id: chainId } as never,
+ transport: custom({
+ async request(args) {
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
+ if (args.method === 'eth_getTransactionCount') return '0x0'
+ if (args.method === 'eth_estimateGas') return '0x5208'
+ if (args.method === 'eth_maxPriorityFeePerGas') return '0x1'
+ if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
+ if (args.method === 'eth_call')
+ return encodeFunctionResult({
+ abi: escrowAbi,
+ functionName: 'getChannelState',
+ result: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
+ })
+ throw new Error(`unexpected rpc request: ${args.method}`)
+ },
+ }),
+})
+
+const descriptor = {
+ payer: account.address,
+ payee: '0x0000000000000000000000000000000000000002' as Address,
+ operator: '0x0000000000000000000000000000000000000000' as Address,
+ token: '0x0000000000000000000000000000000000000003' as Address,
+ salt: `0x${'11'.repeat(32)}` as `0x${string}`,
+ authorizedSigner: account.address,
+ expiringNonceHash: `0x${'22'.repeat(32)}` as `0x${string}`,
+} satisfies Channel.ChannelDescriptor
+
+function makeChallenge(overrides: Record = {}): Challenge {
+ return {
+ id: 'test-id',
+ realm: 'test.com',
+ method: 'tempo',
+ intent: 'session',
+ request: {
+ amount: '100',
+ currency: descriptor.token,
+ recipient: descriptor.payee,
+ methodDetails: { chainId, escrowContract: tip20ChannelEscrow },
+ ...overrides,
+ },
+ }
+}
+
+function deserialize(credential: string) {
+ return Credential.deserialize(credential).payload as Types.SessionCredentialPayload
+}
+
+function openDeposit(payload: Types.SessionCredentialPayload): bigint {
+ if (payload.action !== 'open') throw new Error('expected open payload')
+ const transaction = Transaction.deserialize(payload.transaction)
+ if (!('calls' in transaction)) throw new Error('expected tempo calls')
+ const calls = transaction.calls as readonly { to?: Address; data?: `0x${string}` }[]
+ const call = calls[0]!
+ const decoded = decodeFunctionData({ abi: escrowAbi, data: call.data! })
+ if (decoded.functionName !== 'open') throw new Error('expected open call')
+ return decoded.args[3]
+}
+
+describe('precompile client session', () => {
+ test('throws without action, descriptor, deposit, maxDeposit, or suggestedDeposit', async () => {
+ const method = session({ account, getClient: () => client })
+
+ await expect(
+ method.createCredential({ challenge: makeChallenge() as never, context: {} }),
+ ).rejects.toThrow('No `action` in context and no `deposit` or `maxDeposit` configured')
+ })
+
+ test('uses context depositRaw before configured deposit', async () => {
+ const method = session({ account, deposit: '99', getClient: () => client })
+ const payload = deserialize(
+ await method.createCredential({
+ challenge: makeChallenge() as never,
+ context: { depositRaw: '500' },
+ }),
+ )
+
+ expect(payload.action).toBe('open')
+ expect(openDeposit(payload)).toBe(500n)
+ })
+
+ test('caps suggestedDeposit by maxDeposit', async () => {
+ const method = session({ account, decimals: 0, maxDeposit: '500', getClient: () => client })
+ const payload = deserialize(
+ await method.createCredential({
+ challenge: makeChallenge({ suggestedDeposit: '1000' }) as never,
+ context: {},
+ }),
+ )
+
+ expect(payload.action).toBe('open')
+ expect(openDeposit(payload)).toBe(500n)
+ })
+
+ test('uses suggestedDeposit when below maxDeposit', async () => {
+ const method = session({ account, decimals: 0, maxDeposit: '1000', getClient: () => client })
+ const payload = deserialize(
+ await method.createCredential({
+ challenge: makeChallenge({ suggestedDeposit: '700' }) as never,
+ context: {},
+ }),
+ )
+
+ expect(payload.action).toBe('open')
+ expect(openDeposit(payload)).toBe(700n)
+ })
+
+ test('uses maxDeposit without suggestedDeposit', async () => {
+ const method = session({ account, decimals: 0, maxDeposit: '1000', getClient: () => client })
+ const payload = deserialize(
+ await method.createCredential({ challenge: makeChallenge() as never, context: {} }),
+ )
+
+ expect(payload.action).toBe('open')
+ expect(openDeposit(payload)).toBe(1000n)
+ })
+
+ test('uses canonical precompile address for open transactions', async () => {
+ const challengeEscrow = '0x0000000000000000000000000000000000000005' as Address
+ const method = session({ account, decimals: 0, deposit: '10', getClient: () => client })
+ const payload = deserialize(
+ await method.createCredential({
+ challenge: makeChallenge({
+ methodDetails: { chainId, escrowContract: challengeEscrow },
+ }) as never,
+ context: {},
+ }),
+ )
+
+ if (payload.action !== 'open') throw new Error('expected open payload')
+ const transaction = Transaction.deserialize(payload.transaction)
+ if (!('calls' in transaction)) throw new Error('expected tempo calls')
+ const calls = transaction.calls as readonly { to?: Address; data?: `0x${string}` }[]
+ expect(calls[0]!.to?.toLowerCase()).toBe(tip20ChannelEscrow.toLowerCase())
+ })
+
+ test('tracks cumulative amount and calls onChannelUpdate in auto mode', async () => {
+ const updates: bigint[] = []
+ const method = session({
+ account,
+ decimals: 0,
+ deposit: '1000',
+ getClient: () => client,
+ onChannelUpdate: (entry) => updates.push(entry.cumulativeAmount),
+ })
+ const first = deserialize(
+ await method.createCredential({ challenge: makeChallenge() as never, context: {} }),
+ )
+ const second = deserialize(
+ await method.createCredential({ challenge: makeChallenge() as never, context: {} }),
+ )
+
+ expect(first.action).toBe('open')
+ expect(second.action).toBe('voucher')
+ if (second.action !== 'voucher') throw new Error('expected voucher')
+ expect(second.channelId).toBe(first.channelId)
+ expect(second.cumulativeAmount).toBe('200')
+ expect(updates).toEqual([100n, 200n])
+ })
+
+ test('recovers and reuses a descriptor supplied in context', async () => {
+ const updates: bigint[] = []
+ const method = session({
+ account,
+ decimals: 0,
+ deposit: '1000',
+ getClient: () => client,
+ onChannelUpdate: (entry) => updates.push(entry.cumulativeAmount),
+ })
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
+ const recovered = deserialize(
+ await method.createCredential({
+ challenge: makeChallenge() as never,
+ context: { channelId, descriptor },
+ }),
+ )
+ const next = deserialize(
+ await method.createCredential({ challenge: makeChallenge() as never, context: {} }),
+ )
+
+ expect(recovered.action).toBe('voucher')
+ if (recovered.action !== 'voucher' || next.action !== 'voucher')
+ throw new Error('expected voucher')
+ expect(recovered.channelId).toBe(channelId)
+ expect(recovered.cumulativeAmount).toBe('100')
+ expect(next.channelId).toBe(channelId)
+ expect(next.cumulativeAmount).toBe('200')
+ expect(updates).toEqual([100n, 200n])
+ })
+
+ test('rejects descriptor recovery for closed or missing channels', async () => {
+ const closedClient = createClient({
+ account,
+ chain: { id: chainId } as never,
+ transport: custom({
+ async request(args) {
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
+ if (args.method === 'eth_call')
+ return encodeFunctionResult({
+ abi: escrowAbi,
+ functionName: 'getChannelState',
+ result: { settled: 0n, deposit: 0n, closeRequestedAt: 0 },
+ })
+ throw new Error(`unexpected rpc request: ${args.method}`)
+ },
+ }),
+ })
+ const method = session({ account, decimals: 0, deposit: '1000', getClient: () => closedClient })
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
+
+ await expect(
+ method.createCredential({
+ challenge: makeChallenge() as never,
+ context: { channelId, descriptor },
+ }),
+ ).rejects.toThrow(/cannot be reused \(closed or not found on-chain\)/)
+ })
+
+ test('rejects channel recovery without descriptor', async () => {
+ const method = session({ account, decimals: 0, deposit: '1000', getClient: () => client })
+
+ await expect(
+ method.createCredential({
+ challenge: makeChallenge() as never,
+ context: { channelId: `0x${'33'.repeat(32)}` },
+ }),
+ ).rejects.toThrow('descriptor required to reuse precompile channel')
+ })
+
+ test('defaults precompile authorizedSigner to account access key address', async () => {
+ const accessKeyAddress = '0x0000000000000000000000000000000000000009' as Address
+ const accessKeyAccount = Object.assign({}, account, { accessKeyAddress })
+ const method = session({
+ account: accessKeyAccount,
+ decimals: 0,
+ deposit: '1000',
+ getClient: () => client,
+ })
+ const payload = deserialize(
+ await method.createCredential({ challenge: makeChallenge() as never, context: {} }),
+ )
+
+ expect(payload.action).toBe('open')
+ if (payload.action !== 'open') throw new Error('expected open payload')
+ expect(payload.descriptor.authorizedSigner).toBe(accessKeyAddress)
+ })
+
+ test('manual open requires transaction', async () => {
+ const method = session({ account, getClient: () => client })
+
+ await expect(
+ method.createCredential({
+ challenge: makeChallenge() as never,
+ context: { action: 'open', descriptor, cumulativeAmountRaw: '100' },
+ }),
+ ).rejects.toThrow('transaction required for open action')
+ })
+
+ test('manual open requires cumulativeAmount', async () => {
+ const method = session({ account, getClient: () => client })
+
+ await expect(
+ method.createCredential({
+ challenge: makeChallenge() as never,
+ context: { action: 'open', descriptor, transaction: '0x1234' },
+ }),
+ ).rejects.toThrow('cumulativeAmount required for open action')
+ })
+
+ test('manual topUp requires additionalDeposit', async () => {
+ const method = session({ account, getClient: () => client })
+
+ await expect(
+ method.createCredential({
+ challenge: makeChallenge() as never,
+ context: { action: 'topUp', descriptor, transaction: '0x1234' },
+ }),
+ ).rejects.toThrow('additionalDeposit required for topUp action')
+ })
+
+ test('manual voucher requires cumulativeAmount', async () => {
+ const method = session({ account, getClient: () => client })
+
+ await expect(
+ method.createCredential({
+ challenge: makeChallenge() as never,
+ context: { action: 'voucher', descriptor },
+ }),
+ ).rejects.toThrow('cumulativeAmount required for voucher action')
+ })
+
+ test('manual close requires cumulativeAmount', async () => {
+ const method = session({ account, getClient: () => client })
+
+ await expect(
+ method.createCredential({
+ challenge: makeChallenge() as never,
+ context: { action: 'close', descriptor },
+ }),
+ ).rejects.toThrow('cumulativeAmount required for close action')
+ })
+
+ test('manual actions require descriptors', async () => {
+ const method = session({ account, getClient: () => client })
+
+ await expect(
+ method.createCredential({
+ challenge: makeChallenge() as never,
+ context: { action: 'voucher', cumulativeAmountRaw: '100' },
+ }),
+ ).rejects.toThrow('descriptor required for precompile session action')
+ })
+
+ test('creates manual voucher credentials with descriptor payloads', async () => {
+ const method = session({ account, getClient: () => client })
+ const credential = await method.createCredential({
+ challenge: makeChallenge() as never,
+ context: {
+ action: 'voucher',
+ descriptor,
+ cumulativeAmountRaw: '250',
+ },
+ })
+
+ const decoded = Credential.deserialize(credential)
+ const payload = decoded.payload as Types.SessionCredentialPayload
+ const cumulativeAmount = Types.uint96(250n)
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
+
+ expect(payload.action).toBe('voucher')
+ if (payload.action !== 'voucher') throw new Error('expected voucher payload')
+ expect(payload.channelId).toBe(channelId)
+ expect(payload.descriptor).toEqual(descriptor)
+ expect(payload.cumulativeAmount).toBe('250')
+ expect(decoded.source).toBe(`did:pkh:eip155:${chainId}:${account.address}`)
+ expect(
+ Voucher.verifyVoucher(
+ tip20ChannelEscrow,
+ chainId,
+ { channelId, cumulativeAmount, signature: payload.signature },
+ descriptor.authorizedSigner,
+ ),
+ ).toBe(true)
+ })
+
+ test('creates manual top-up credentials from provided transactions', async () => {
+ const method = session({ account, getClient: () => client })
+ const credential = await method.createCredential({
+ challenge: makeChallenge() as never,
+ context: {
+ action: 'topUp',
+ descriptor,
+ additionalDepositRaw: '500',
+ transaction: '0x1234',
+ },
+ })
+
+ const decoded = Credential.deserialize(credential)
+ const payload = decoded.payload as Types.SessionCredentialPayload
+
+ expect(payload.action).toBe('topUp')
+ if (payload.action !== 'topUp') throw new Error('expected topUp payload')
+ expect(payload.descriptor).toEqual(descriptor)
+ expect(payload.additionalDeposit).toBe('500')
+ expect(payload.transaction).toBe('0x1234')
+ })
+})
diff --git a/src/tempo/precompile/client/Session.ts b/src/tempo/precompile/client/Session.ts
new file mode 100644
index 00000000..cb523af5
--- /dev/null
+++ b/src/tempo/precompile/client/Session.ts
@@ -0,0 +1,344 @@
+import { type Address, parseUnits, type Account as viem_Account } from 'viem'
+import { tempo as tempo_chain } from 'viem/chains'
+
+import type * as Challenge from '../../../Challenge.js'
+import * as Method from '../../../Method.js'
+import * as Account from '../../../viem/Account.js'
+import * as Client from '../../../viem/Client.js'
+import * as z from '../../../zod.js'
+import { resolveChainId } from '../../client/ChannelOps.js'
+import * as defaults from '../../internal/defaults.js'
+import * as Methods from '../../Methods.js'
+import * as Chain from '../Chain.js'
+import * as Channel from '../Channel.js'
+import type { SessionCredentialPayload } from '../Types.js'
+import {
+ createOpenPayload,
+ createTopUpPayload,
+ createVoucherPayload,
+ isSameAddress,
+ resolveEscrow,
+ serializeCredential,
+ type ChannelEntry,
+} from './ChannelOps.js'
+
+export const sessionContextSchema = z.object({
+ account: z.optional(z.custom()),
+ action: z.optional(z.enum(['open', 'topUp', 'voucher', 'close'])),
+ channelId: z.optional(z.string()),
+ cumulativeAmount: z.optional(z.amount()),
+ cumulativeAmountRaw: z.optional(z.string()),
+ transaction: z.optional(z.string()),
+ descriptor: z.optional(z.custom()),
+ additionalDeposit: z.optional(z.amount()),
+ additionalDepositRaw: z.optional(z.string()),
+ depositRaw: z.optional(z.string()),
+})
+
+export type SessionContext = z.infer
+
+/**
+ * Creates a precompile-backed session payment method for use with `Mppx.create()`.
+ *
+ * Supports both auto mode (set `deposit` to manage channels automatically)
+ * and manual mode (pass `context.action` with a channel descriptor to control each step).
+ */
+export function session(parameters: session.Parameters = {}) {
+ const { decimals = defaults.decimals } = parameters
+
+ const getClient = Client.getResolver({
+ chain: tempo_chain,
+ getClient: parameters.getClient,
+ rpcUrl: defaults.rpcUrl,
+ })
+ const getAccount = Account.getResolver({ account: parameters.account })
+
+ const maxDeposit =
+ parameters.maxDeposit !== undefined ? parseUnits(parameters.maxDeposit, decimals) : undefined
+
+ const channels = new Map()
+ const channelIdToKey = new Map()
+
+ function notifyUpdate(entry: ChannelEntry) {
+ parameters.onChannelUpdate?.(entry)
+ }
+
+ function channelKey(payee: Address, token: Address, escrow: Address): string {
+ return `${payee.toLowerCase()}:${token.toLowerCase()}:${escrow.toLowerCase()}`
+ }
+
+ async function autoManageCredential(
+ challenge: Challenge.Challenge,
+ account: viem_Account,
+ context?: SessionContext,
+ ): Promise {
+ const methodDetails = challenge.request.methodDetails as { feePayer?: boolean } | undefined
+ const chainId = resolveChainId(challenge)
+ const client = await getClient({ chainId })
+ const escrow = resolveEscrow(challenge, parameters.escrow)
+ const payee = challenge.request.recipient as Address
+ const token = challenge.request.currency as Address
+ const amount = BigInt(challenge.request.amount as string)
+ const key = channelKey(payee, token, escrow)
+ let entry = channels.get(key)
+
+ let payload: SessionCredentialPayload
+ if (!entry && context?.channelId && !context.descriptor)
+ throw new Error('descriptor required to reuse precompile channel')
+ if (!entry && context?.descriptor) {
+ const channelId = Channel.computeId({ ...context.descriptor, chainId, escrow })
+ if (context.channelId && context.channelId.toLowerCase() !== channelId.toLowerCase())
+ throw new Error('context channelId does not match descriptor')
+ if (!isSameAddress(context.descriptor.payee, payee))
+ throw new Error('context descriptor payee does not match challenge')
+ if (!isSameAddress(context.descriptor.token, token))
+ throw new Error('context descriptor token does not match challenge')
+ const state = await Chain.getChannelState(client, channelId, escrow)
+ if (state.deposit === 0n)
+ throw new Error(`Channel ${channelId} cannot be reused (closed or not found on-chain).`)
+ if (state.closeRequestedAt !== 0)
+ throw new Error(`Channel ${channelId} cannot be reused (pending close request).`)
+ const contextCumulative = context.cumulativeAmountRaw
+ ? BigInt(context.cumulativeAmountRaw)
+ : context.cumulativeAmount
+ ? parseUnits(context.cumulativeAmount, decimals)
+ : undefined
+ const cumulativeAmount =
+ contextCumulative === undefined ? state.settled + amount : contextCumulative
+ payload = await createVoucherPayload(
+ client,
+ account,
+ context.descriptor,
+ cumulativeAmount,
+ chainId,
+ )
+ entry = {
+ channelId,
+ cumulativeAmount,
+ descriptor: context.descriptor,
+ escrow,
+ chainId,
+ opened: true,
+ }
+ channels.set(key, entry)
+ channelIdToKey.set(channelId, key)
+ notifyUpdate(entry)
+ } else if (entry?.opened) {
+ const cumulativeAmount = entry.cumulativeAmount + amount
+ payload = await createVoucherPayload(
+ client,
+ account,
+ entry.descriptor,
+ cumulativeAmount,
+ chainId,
+ )
+ entry.cumulativeAmount = cumulativeAmount
+ notifyUpdate(entry)
+ } else {
+ const suggestedDepositRaw = (challenge.request as { suggestedDeposit?: string })
+ .suggestedDeposit
+ const deposit = (() => {
+ if (context?.depositRaw) return BigInt(context.depositRaw)
+ if (parameters.deposit !== undefined) return parseUnits(parameters.deposit, decimals)
+ const suggestedDeposit =
+ suggestedDepositRaw !== undefined ? BigInt(suggestedDepositRaw) : undefined
+ if (suggestedDeposit !== undefined && maxDeposit !== undefined)
+ return suggestedDeposit < maxDeposit ? suggestedDeposit : maxDeposit
+ if (maxDeposit !== undefined) return maxDeposit
+ if (suggestedDeposit !== undefined) return suggestedDeposit
+ throw new Error(
+ 'No deposit amount available. Set `deposit`, `maxDeposit`, or ensure the server challenge includes `suggestedDeposit`.',
+ )
+ })()
+ payload = await createOpenPayload(client, account, {
+ authorizedSigner: parameters.authorizedSigner,
+ chainId,
+ deposit,
+ feePayer: methodDetails?.feePayer,
+ initialAmount: amount,
+ operator: parameters.operator,
+ payee,
+ token,
+ })
+ if (payload.action !== 'open') throw new Error('expected open payload')
+ const entry: ChannelEntry = {
+ channelId: payload.channelId,
+ cumulativeAmount: amount,
+ descriptor: payload.descriptor,
+ escrow,
+ chainId,
+ opened: true,
+ }
+ channels.set(key, entry)
+ channelIdToKey.set(payload.channelId, key)
+ notifyUpdate(entry)
+ }
+
+ return serializeCredential(challenge, payload, chainId, account)
+ }
+
+ async function manualCredential(
+ challenge: Challenge.Challenge,
+ account: viem_Account,
+ context: SessionContext,
+ ): Promise {
+ const chainId = resolveChainId(challenge)
+ const client = await getClient({ chainId })
+ const escrow = resolveEscrow(challenge, parameters.escrow)
+ const action = context.action!
+ const descriptor = context.descriptor
+ if (!descriptor) throw new Error('descriptor required for precompile session action')
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow })
+
+ let payload: SessionCredentialPayload
+ switch (action) {
+ case 'open': {
+ if (!context.transaction) throw new Error('transaction required for open action')
+ const cumulativeAmountRaw = context.cumulativeAmountRaw
+ ? BigInt(context.cumulativeAmountRaw)
+ : context.cumulativeAmount
+ ? parseUnits(context.cumulativeAmount, decimals)
+ : undefined
+ if (cumulativeAmountRaw === undefined)
+ throw new Error('cumulativeAmount required for open action')
+ const cumulativeAmount = cumulativeAmountRaw
+ const voucher = await createVoucherPayload(
+ client,
+ account,
+ descriptor,
+ cumulativeAmount,
+ chainId,
+ )
+ if (voucher.action !== 'voucher') throw new Error('expected voucher payload')
+ payload = {
+ action: 'open',
+ type: 'transaction',
+ channelId,
+ transaction: context.transaction as `0x${string}`,
+ signature: voucher.signature,
+ descriptor,
+ cumulativeAmount: cumulativeAmount.toString(),
+ authorizedSigner: descriptor.authorizedSigner,
+ }
+ break
+ }
+ case 'topUp': {
+ const additionalDepositRaw = context.additionalDepositRaw
+ ? BigInt(context.additionalDepositRaw)
+ : context.additionalDeposit
+ ? parseUnits(context.additionalDeposit, decimals)
+ : undefined
+ if (additionalDepositRaw === undefined)
+ throw new Error('additionalDeposit required for topUp action')
+ const additionalDeposit = additionalDepositRaw
+ if (context.transaction) {
+ payload = {
+ action: 'topUp',
+ type: 'transaction',
+ channelId,
+ transaction: context.transaction as `0x${string}`,
+ descriptor,
+ additionalDeposit: additionalDeposit.toString(),
+ }
+ } else {
+ payload = await createTopUpPayload(
+ client,
+ account,
+ descriptor,
+ additionalDeposit,
+ chainId,
+ (challenge.request.methodDetails as { feePayer?: boolean } | undefined)?.feePayer,
+ )
+ }
+ break
+ }
+ case 'voucher': {
+ const cumulativeAmountRaw = context.cumulativeAmountRaw
+ ? BigInt(context.cumulativeAmountRaw)
+ : context.cumulativeAmount
+ ? parseUnits(context.cumulativeAmount, decimals)
+ : undefined
+ if (cumulativeAmountRaw === undefined)
+ throw new Error('cumulativeAmount required for voucher action')
+ const cumulativeAmount = cumulativeAmountRaw
+ payload = await createVoucherPayload(client, account, descriptor, cumulativeAmount, chainId)
+ break
+ }
+ case 'close': {
+ const cumulativeAmountRaw = context.cumulativeAmountRaw
+ ? BigInt(context.cumulativeAmountRaw)
+ : context.cumulativeAmount
+ ? parseUnits(context.cumulativeAmount, decimals)
+ : undefined
+ if (cumulativeAmountRaw === undefined)
+ throw new Error('cumulativeAmount required for close action')
+ const cumulativeAmount = cumulativeAmountRaw
+ const voucher = await createVoucherPayload(
+ client,
+ account,
+ descriptor,
+ cumulativeAmount,
+ chainId,
+ )
+ if (voucher.action !== 'voucher') throw new Error('expected voucher payload')
+ payload = { ...voucher, action: 'close' }
+ break
+ }
+ }
+
+ const key = channelIdToKey.get(channelId)
+ if (key) {
+ const entry = channels.get(key)
+ if (entry && 'cumulativeAmount' in payload) {
+ const cumulativeAmount = BigInt(payload.cumulativeAmount)
+ entry.cumulativeAmount =
+ entry.cumulativeAmount > cumulativeAmount ? entry.cumulativeAmount : cumulativeAmount
+ if (payload.action === 'close') entry.opened = false
+ notifyUpdate(entry)
+ }
+ }
+
+ return serializeCredential(challenge, payload, chainId, account)
+ }
+
+ return Method.toClient(Methods.session, {
+ context: sessionContextSchema,
+ async createCredential({ challenge, context }) {
+ const chainId = resolveChainId(challenge)
+ const client = await getClient({ chainId })
+ const account = getAccount(client, context)
+
+ if (!context?.action && (parameters.deposit !== undefined || maxDeposit !== undefined))
+ return autoManageCredential(challenge, account, context)
+
+ if (!context?.action && (challenge.request as { suggestedDeposit?: string }).suggestedDeposit)
+ return autoManageCredential(challenge, account, context)
+
+ if (context?.action) return manualCredential(challenge, account, context)
+
+ throw new Error(
+ 'No `action` in context and no `deposit` or `maxDeposit` configured. Either provide context with action/descriptor/cumulativeAmount, or configure `deposit`/`maxDeposit` for auto-management.',
+ )
+ },
+ })
+}
+
+export declare namespace session {
+ type Parameters = Account.getResolver.Parameters &
+ Client.getResolver.Parameters & {
+ /** Address authorized to sign vouchers on behalf of the payer. Defaults to the account access key address when available, otherwise the account address. */
+ authorizedSigner?: Address | undefined
+ /** Token decimals for parsing human-readable amounts (default: 6). */
+ decimals?: number | undefined
+ /** Initial deposit amount in human-readable units. */
+ deposit?: string | undefined
+ /** TIP20EscrowChannel precompile address override. */
+ escrow?: Address | undefined
+ /** Maximum deposit in human-readable units. Caps the server suggestedDeposit and enables auto-management. */
+ maxDeposit?: string | undefined
+ /** Address authorized to operate the precompile channel on behalf of the payee. */
+ operator?: Address | undefined
+ /** Called whenever channel state changes. */
+ onChannelUpdate?: ((entry: ChannelEntry) => void) | undefined
+ }
+}
diff --git a/src/tempo/precompile/client/index.ts b/src/tempo/precompile/client/index.ts
new file mode 100644
index 00000000..b7621b78
--- /dev/null
+++ b/src/tempo/precompile/client/index.ts
@@ -0,0 +1,2 @@
+export * as ChannelOps from './ChannelOps.js'
+export { session } from './Session.js'
diff --git a/src/tempo/precompile/escrow.abi.ts b/src/tempo/precompile/escrow.abi.ts
new file mode 100644
index 00000000..49e1ad1a
--- /dev/null
+++ b/src/tempo/precompile/escrow.abi.ts
@@ -0,0 +1,226 @@
+const channelDescriptorComponents = [
+ { name: 'payer', type: 'address' },
+ { name: 'payee', type: 'address' },
+ { name: 'operator', type: 'address' },
+ { name: 'token', type: 'address' },
+ { name: 'salt', type: 'bytes32' },
+ { name: 'authorizedSigner', type: 'address' },
+ { name: 'expiringNonceHash', type: 'bytes32' },
+] as const
+
+const channelStateComponents = [
+ { name: 'settled', type: 'uint96' },
+ { name: 'deposit', type: 'uint96' },
+ { name: 'closeRequestedAt', type: 'uint32' },
+] as const
+
+const channelDescriptorInput = {
+ name: 'descriptor',
+ type: 'tuple',
+ components: channelDescriptorComponents,
+} as const
+
+/** ABI for the TIP-1034 TIP-20 Channel Escrow precompile. */
+export const escrowAbi = [
+ {
+ type: 'function',
+ name: 'CLOSE_GRACE_PERIOD',
+ stateMutability: 'view',
+ inputs: [],
+ outputs: [{ type: 'uint64' }],
+ },
+ {
+ type: 'function',
+ name: 'VOUCHER_TYPEHASH',
+ stateMutability: 'view',
+ inputs: [],
+ outputs: [{ type: 'bytes32' }],
+ },
+ {
+ type: 'function',
+ name: 'open',
+ stateMutability: 'nonpayable',
+ inputs: [
+ { name: 'payee', type: 'address' },
+ { name: 'operator', type: 'address' },
+ { name: 'token', type: 'address' },
+ { name: 'deposit', type: 'uint96' },
+ { name: 'salt', type: 'bytes32' },
+ { name: 'authorizedSigner', type: 'address' },
+ ],
+ outputs: [{ name: 'channelId', type: 'bytes32' }],
+ },
+ {
+ type: 'function',
+ name: 'settle',
+ stateMutability: 'nonpayable',
+ inputs: [
+ channelDescriptorInput,
+ { name: 'cumulativeAmount', type: 'uint96' },
+ { name: 'signature', type: 'bytes' },
+ ],
+ outputs: [],
+ },
+ {
+ type: 'function',
+ name: 'topUp',
+ stateMutability: 'nonpayable',
+ inputs: [channelDescriptorInput, { name: 'additionalDeposit', type: 'uint96' }],
+ outputs: [],
+ },
+ {
+ type: 'function',
+ name: 'close',
+ stateMutability: 'nonpayable',
+ inputs: [
+ channelDescriptorInput,
+ { name: 'cumulativeAmount', type: 'uint96' },
+ { name: 'captureAmount', type: 'uint96' },
+ { name: 'signature', type: 'bytes' },
+ ],
+ outputs: [],
+ },
+ {
+ type: 'function',
+ name: 'requestClose',
+ stateMutability: 'nonpayable',
+ inputs: [channelDescriptorInput],
+ outputs: [],
+ },
+ {
+ type: 'function',
+ name: 'withdraw',
+ stateMutability: 'nonpayable',
+ inputs: [channelDescriptorInput],
+ outputs: [],
+ },
+ {
+ type: 'function',
+ name: 'getChannel',
+ stateMutability: 'view',
+ inputs: [channelDescriptorInput],
+ outputs: [
+ {
+ type: 'tuple',
+ components: [
+ { name: 'descriptor', type: 'tuple', components: channelDescriptorComponents },
+ { name: 'state', type: 'tuple', components: channelStateComponents },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'function',
+ name: 'getChannelState',
+ stateMutability: 'view',
+ inputs: [{ name: 'channelId', type: 'bytes32' }],
+ outputs: [{ type: 'tuple', components: channelStateComponents }],
+ },
+ {
+ type: 'function',
+ name: 'getChannelStatesBatch',
+ stateMutability: 'view',
+ inputs: [{ name: 'channelIds', type: 'bytes32[]' }],
+ outputs: [{ type: 'tuple[]', components: channelStateComponents }],
+ },
+ {
+ type: 'function',
+ name: 'computeChannelId',
+ stateMutability: 'view',
+ inputs: [
+ { name: 'payer', type: 'address' },
+ { name: 'payee', type: 'address' },
+ { name: 'operator', type: 'address' },
+ { name: 'token', type: 'address' },
+ { name: 'salt', type: 'bytes32' },
+ { name: 'authorizedSigner', type: 'address' },
+ { name: 'expiringNonceHash', type: 'bytes32' },
+ ],
+ outputs: [{ type: 'bytes32' }],
+ },
+ {
+ type: 'function',
+ name: 'getVoucherDigest',
+ stateMutability: 'view',
+ inputs: [
+ { name: 'channelId', type: 'bytes32' },
+ { name: 'cumulativeAmount', type: 'uint96' },
+ ],
+ outputs: [{ type: 'bytes32' }],
+ },
+ {
+ type: 'function',
+ name: 'domainSeparator',
+ stateMutability: 'view',
+ inputs: [],
+ outputs: [{ type: 'bytes32' }],
+ },
+ {
+ type: 'event',
+ name: 'ChannelOpened',
+ inputs: [
+ { name: 'channelId', type: 'bytes32', indexed: true },
+ { name: 'payer', type: 'address', indexed: true },
+ { name: 'payee', type: 'address', indexed: true },
+ { name: 'operator', type: 'address' },
+ { name: 'token', type: 'address' },
+ { name: 'authorizedSigner', type: 'address' },
+ { name: 'salt', type: 'bytes32' },
+ { name: 'expiringNonceHash', type: 'bytes32' },
+ { name: 'deposit', type: 'uint96' },
+ ],
+ },
+ {
+ type: 'event',
+ name: 'Settled',
+ inputs: [
+ { name: 'channelId', type: 'bytes32', indexed: true },
+ { name: 'payer', type: 'address', indexed: true },
+ { name: 'payee', type: 'address', indexed: true },
+ { name: 'cumulativeAmount', type: 'uint96' },
+ { name: 'deltaPaid', type: 'uint96' },
+ { name: 'newSettled', type: 'uint96' },
+ ],
+ },
+ {
+ type: 'event',
+ name: 'TopUp',
+ inputs: [
+ { name: 'channelId', type: 'bytes32', indexed: true },
+ { name: 'payer', type: 'address', indexed: true },
+ { name: 'payee', type: 'address', indexed: true },
+ { name: 'additionalDeposit', type: 'uint96' },
+ { name: 'newDeposit', type: 'uint96' },
+ ],
+ },
+ {
+ type: 'event',
+ name: 'CloseRequested',
+ inputs: [
+ { name: 'channelId', type: 'bytes32', indexed: true },
+ { name: 'payer', type: 'address', indexed: true },
+ { name: 'payee', type: 'address', indexed: true },
+ { name: 'closeGraceEnd', type: 'uint256' },
+ ],
+ },
+ {
+ type: 'event',
+ name: 'ChannelClosed',
+ inputs: [
+ { name: 'channelId', type: 'bytes32', indexed: true },
+ { name: 'payer', type: 'address', indexed: true },
+ { name: 'payee', type: 'address', indexed: true },
+ { name: 'settledToPayee', type: 'uint96' },
+ { name: 'refundedToPayer', type: 'uint96' },
+ ],
+ },
+ {
+ type: 'event',
+ name: 'CloseRequestCancelled',
+ inputs: [
+ { name: 'channelId', type: 'bytes32', indexed: true },
+ { name: 'payer', type: 'address', indexed: true },
+ { name: 'payee', type: 'address', indexed: true },
+ ],
+ },
+] as const
diff --git a/src/tempo/precompile/index.ts b/src/tempo/precompile/index.ts
new file mode 100644
index 00000000..6b8e25f3
--- /dev/null
+++ b/src/tempo/precompile/index.ts
@@ -0,0 +1,11 @@
+export * as Client from './client/index.js'
+export { session } from './client/Session.js'
+export { sessionManager } from './session/SessionManager.js'
+export * as Session from './session/index.js'
+export * as Chain from './Chain.js'
+export * as Channel from './Channel.js'
+export * as Constants from './Constants.js'
+export * as Server from './server/index.js'
+export * as Types from './Types.js'
+export * as Voucher from './Voucher.js'
+export { escrowAbi } from './escrow.abi.js'
diff --git a/src/tempo/precompile/server/Authorize.integration.test.ts b/src/tempo/precompile/server/Authorize.integration.test.ts
new file mode 100644
index 00000000..e0fb7a9c
--- /dev/null
+++ b/src/tempo/precompile/server/Authorize.integration.test.ts
@@ -0,0 +1,316 @@
+import { Challenge, Credential, type z } from 'mppx'
+import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
+import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
+import type { Address } from 'viem'
+import { describe, expect, test } from 'vp/test'
+import { nodeEnv } from '~test/config.js'
+import * as Http from '~test/Http.js'
+import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
+
+import * as Store from '../../../Store.js'
+import * as AuthorizeStore from '../../authorize/Store.js'
+import * as Methods from '../../Methods.js'
+import { getChannelState } from '../Chain.js'
+import { tip20ChannelEscrow } from '../Constants.js'
+
+const isPrecompileTestnet = nodeEnv === 'localnet' || nodeEnv === 'devnet'
+const keyPrefix = 'integration:tempo:authorize:'
+const payer = accounts[2]
+const payee = accounts[0]
+const realm = 'api.example.com'
+const secretKey = 'test-secret-key'
+
+type AuthorizeRequestInput = z.input
+type BindingCase = {
+ label: string
+ credentialRequest: Partial
+ submitRequest?: Partial | undefined
+ mutateCredential?: ((credential: Credential.Credential) => Credential.Credential) | undefined
+}
+
+function createServerMethod(
+ rawStore: Store.AtomicStore,
+ options: Partial[0]> = {},
+) {
+ return tempo_server.authorize({
+ account: payee,
+ amount: '1000',
+ chainId: chain.id,
+ currency: asset,
+ decimals: 0,
+ getClient: () => client,
+ keyPrefix,
+ store: rawStore,
+ ...options,
+ })
+}
+
+function createClientMppx() {
+ return Mppx_client.create({
+ polyfill: false,
+ methods: [
+ tempo_client.authorize({
+ account: payer,
+ getClient: () => client,
+ }),
+ ],
+ })
+}
+
+function authorizeRequest(overrides: Partial = {}) {
+ return {
+ amount: '1000',
+ authorizedSigner: payee.address,
+ chainId: chain.id,
+ currency: asset,
+ decimals: 0,
+ escrowContract: tip20ChannelEscrow,
+ operator: payee.address,
+ recipient: payee.address,
+ ...overrides,
+ } satisfies AuthorizeRequestInput
+}
+
+async function createCredentialFor(request: AuthorizeRequestInput): Promise {
+ const challenge = Challenge.fromMethod(Methods.authorize, {
+ expires: new Date(Date.now() + 60_000).toISOString(),
+ realm,
+ request,
+ secretKey,
+ })
+ const method = createClientMppx().methods[0]!
+ return Credential.deserialize(
+ await method.createCredential({ challenge: challenge as never, context: {} }),
+ )
+}
+
+async function registerAuthorization(options: { amount?: string; captureKeyPrefix?: string } = {}) {
+ await fundAccount({ address: payer.address, token: asset })
+
+ const rawStore = Store.memory()
+ const authorizationStore = AuthorizeStore.fromStore(rawStore, { keyPrefix })
+ const serverMppx = Mppx_server.create({
+ methods: [createServerMethod(rawStore, { amount: options.amount ?? '1000' })],
+ realm,
+ secretKey,
+ })
+ const clientMppx = createClientMppx()
+
+ const httpServer = await Http.createServer(async (req, res) => {
+ const result = await Mppx_server.toNodeListener(serverMppx.authorize(authorizeRequest()))(
+ req,
+ res,
+ )
+ if (result.status === 402) return
+ res.end('unexpected protected handler')
+ })
+
+ try {
+ const challengeResponse = await fetch(httpServer.url)
+ expect(challengeResponse.status).toBe(402)
+
+ const credential = await clientMppx.createCredential(challengeResponse)
+ const authorizationResponse = await fetch(httpServer.url, {
+ headers: { Authorization: credential },
+ })
+ expect(authorizationResponse.status).toBe(200)
+ expect(authorizationResponse.headers.get('Payment-Receipt')).toBeNull()
+
+ const body = (await authorizationResponse.json()) as {
+ authorization: { id: `0x${string}`; amount: string; capturedAmount: string }
+ }
+ return { authorizationStore, body, rawStore }
+ } finally {
+ httpServer.close()
+ }
+}
+
+describe.runIf(isPrecompileTestnet)('precompile server authorize integration', () => {
+ test('registers and captures a real authorize channel over HTTP', async () => {
+ const { authorizationStore, body, rawStore } = await registerAuthorization()
+ expect(body.authorization.amount).toBe('1000')
+ expect(body.authorization.capturedAmount).toBe('0')
+
+ const authorization = await authorizationStore.get(body.authorization.id)
+ expect(authorization?.amount).toBe('1000')
+ expect(authorization?.channel.id).toBe(body.authorization.id)
+
+ const receipt = await tempo_server.capture(rawStore, client, body.authorization.id, {
+ account: payee,
+ amount: '250',
+ keyPrefix,
+ })
+ expect(receipt.authorizationId).toBe(body.authorization.id)
+ expect(receipt.capturedAmount).toBe('250')
+ expect(receipt.delta).toBe('250')
+
+ const state = await getChannelState(client, body.authorization.id, tip20ChannelEscrow)
+ expect(state.deposit).toBe(1000n)
+ expect(state.settled).toBe(250n)
+ })
+
+ const bindingCases: BindingCase[] = [
+ {
+ label: 'channelId',
+ credentialRequest: {},
+ submitRequest: {},
+ mutateCredential(credential: Credential.Credential) {
+ return Credential.from({
+ challenge: credential.challenge,
+ payload: {
+ ...(credential.payload as Record),
+ channelId: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ },
+ })
+ },
+ },
+ {
+ label: 'amount',
+ credentialRequest: { amount: '999' },
+ submitRequest: {},
+ },
+ {
+ label: 'recipient',
+ credentialRequest: { recipient: accounts[3].address as Address },
+ submitRequest: {},
+ },
+ {
+ label: 'operator',
+ credentialRequest: { operator: accounts[3].address as Address },
+ submitRequest: {},
+ },
+ {
+ label: 'authorizedSigner',
+ credentialRequest: { authorizedSigner: accounts[3].address as Address },
+ submitRequest: {},
+ },
+ ]
+
+ test.each(bindingCases)(
+ 'rejects tampered authorize credential binding: $label',
+ async (entry) => {
+ await fundAccount({ address: payer.address, token: asset })
+
+ const rawStore = Store.memory()
+ const method = createServerMethod(rawStore)
+ const credential = await createCredentialFor(authorizeRequest(entry.credentialRequest))
+ const submittedCredential = entry.mutateCredential?.(credential) ?? credential
+
+ await expect(
+ method.verify({
+ credential: submittedCredential as never,
+ request: authorizeRequest(entry.submitRequest ?? {}),
+ }),
+ ).rejects.toThrow()
+ },
+ )
+
+ test('rejects replayed authorize credentials', async () => {
+ await fundAccount({ address: payer.address, token: asset })
+
+ const rawStore = Store.memory()
+ const serverMppx = Mppx_server.create({
+ methods: [createServerMethod(rawStore)],
+ realm,
+ secretKey,
+ })
+ const clientMppx = createClientMppx()
+ const httpServer = await Http.createServer(async (req, res) => {
+ const result = await Mppx_server.toNodeListener(serverMppx.authorize(authorizeRequest()))(
+ req,
+ res,
+ )
+ if (result.status === 402) return
+ res.end('unexpected protected handler')
+ })
+
+ try {
+ const challengeResponse = await fetch(httpServer.url)
+ const credential = await clientMppx.createCredential(challengeResponse)
+ expect((await fetch(httpServer.url, { headers: { Authorization: credential } })).status).toBe(
+ 200,
+ )
+
+ const replay = await fetch(httpServer.url, { headers: { Authorization: credential } })
+ expect(replay.status).toBe(402)
+ expect(((await replay.json()) as { detail: string }).detail).toContain('already been used')
+ } finally {
+ httpServer.close()
+ }
+ })
+
+ test('rejects over-capture', async () => {
+ const { body, rawStore } = await registerAuthorization()
+
+ await expect(
+ tempo_server.capture(rawStore, client, body.authorization.id, {
+ account: payee,
+ amount: '1001',
+ keyPrefix,
+ }),
+ ).rejects.toThrow('capture exceeds authorized amount')
+ })
+
+ test('returns the same receipt for repeated capture idempotency keys', async () => {
+ const { body, rawStore } = await registerAuthorization()
+
+ const first = await tempo_server.capture(rawStore, client, body.authorization.id, {
+ account: payee,
+ amount: '250',
+ idempotencyKey: 'capture-1',
+ keyPrefix,
+ })
+ const second = await tempo_server.capture(rawStore, client, body.authorization.id, {
+ account: payee,
+ amount: '250',
+ idempotencyKey: 'capture-1',
+ keyPrefix,
+ })
+
+ expect(second).toEqual(first)
+ })
+
+ test('captures partially, closes with remaining amount, then rejects later capture', async () => {
+ const { body, rawStore } = await registerAuthorization()
+
+ await tempo_server.capture(rawStore, client, body.authorization.id, {
+ account: payee,
+ amount: '250',
+ keyPrefix,
+ })
+ const closed = await tempo_server.capture(rawStore, client, body.authorization.id, {
+ account: payee,
+ amount: '750',
+ close: true,
+ keyPrefix,
+ })
+ expect(closed.capturedAmount).toBe('1000')
+
+ await expect(
+ tempo_server.capture(rawStore, client, body.authorization.id, {
+ account: payee,
+ amount: '1',
+ keyPrefix,
+ }),
+ ).rejects.toThrow('authorization is closed')
+ })
+
+ test('voids an authorization and rejects subsequent capture', async () => {
+ const { authorizationStore, body, rawStore } = await registerAuthorization()
+
+ const receipt = await tempo_server.voidAuthorization(rawStore, client, body.authorization.id, {
+ account: payee,
+ keyPrefix,
+ })
+ expect(receipt.status).toBe('voided')
+ expect((await authorizationStore.get(body.authorization.id))?.status).toBe('voided')
+
+ await expect(
+ tempo_server.capture(rawStore, client, body.authorization.id, {
+ account: payee,
+ amount: '1',
+ keyPrefix,
+ }),
+ ).rejects.toThrow('authorization is voided')
+ })
+})
diff --git a/src/tempo/precompile/server/ChannelOps.ts b/src/tempo/precompile/server/ChannelOps.ts
new file mode 100644
index 00000000..274fe02e
--- /dev/null
+++ b/src/tempo/precompile/server/ChannelOps.ts
@@ -0,0 +1,119 @@
+import type { Address, Hex } from 'viem'
+import { decodeFunctionData, isAddressEqual } from 'viem'
+
+import * as Channel from '../Channel.js'
+import { tip20ChannelEscrow } from '../Constants.js'
+import { escrowAbi } from '../escrow.abi.js'
+import type { Uint96 } from '../Types.js'
+import { uint96 } from '../Types.js'
+
+/** Validates that calldata contains exactly one TIP-1034 approve-less `open` call. */
+export function parseOpenCall(parameters: {
+ data: Hex
+ expected?:
+ | {
+ authorizedSigner?: Address | undefined
+ deposit?: Uint96 | undefined
+ operator?: Address | undefined
+ payee?: Address | undefined
+ token?: Address | undefined
+ }
+ | undefined
+}) {
+ let decoded: ReturnType>
+ try {
+ decoded = decodeFunctionData({ abi: escrowAbi, data: parameters.data })
+ } catch {
+ throw new Error('Expected TIP-1034 open calldata.')
+ }
+ if (decoded.functionName !== 'open') throw new Error('Expected TIP-1034 open calldata.')
+ const [payee, operator, token, deposit, salt, authorizedSigner] = decoded.args
+ const expected = parameters.expected
+ if (expected?.payee && !isAddressEqual(payee, expected.payee))
+ throw new Error('TIP-1034 open payee does not match challenge.')
+ if (expected?.operator && !isAddressEqual(operator, expected.operator))
+ throw new Error('TIP-1034 open operator does not match challenge.')
+ if (expected?.token && !isAddressEqual(token, expected.token))
+ throw new Error('TIP-1034 open token does not match challenge.')
+ if (expected?.authorizedSigner && !isAddressEqual(authorizedSigner, expected.authorizedSigner))
+ throw new Error('TIP-1034 open authorizedSigner does not match credential.')
+ const validatedDeposit = uint96(deposit)
+ if (expected?.deposit !== undefined && validatedDeposit !== expected.deposit)
+ throw new Error('TIP-1034 open deposit does not match challenge.')
+ return { payee, operator, token, deposit: validatedDeposit, salt, authorizedSigner }
+}
+
+/** Validates that calldata contains exactly one TIP-1034 descriptor-based `topUp` call. */
+export function parseTopUpCall(parameters: {
+ data: Hex
+ expected?:
+ | {
+ descriptor?: Channel.ChannelDescriptor | undefined
+ additionalDeposit?: Uint96 | undefined
+ }
+ | undefined
+}) {
+ let decoded: ReturnType>
+ try {
+ decoded = decodeFunctionData({ abi: escrowAbi, data: parameters.data })
+ } catch {
+ throw new Error('Expected TIP-1034 topUp calldata.')
+ }
+ if (decoded.functionName !== 'topUp') throw new Error('Expected TIP-1034 topUp calldata.')
+ const [descriptor, additionalDeposit] = decoded.args
+ const expected = parameters.expected
+ if (expected?.descriptor) {
+ const actual = descriptor as Channel.ChannelDescriptor
+ const wanted = expected.descriptor
+ if (
+ !isAddressEqual(actual.payer, wanted.payer) ||
+ !isAddressEqual(actual.payee, wanted.payee) ||
+ !isAddressEqual(actual.operator, wanted.operator) ||
+ !isAddressEqual(actual.token, wanted.token) ||
+ !isAddressEqual(actual.authorizedSigner, wanted.authorizedSigner) ||
+ actual.salt.toLowerCase() !== wanted.salt.toLowerCase() ||
+ actual.expiringNonceHash.toLowerCase() !== wanted.expiringNonceHash.toLowerCase()
+ )
+ throw new Error('TIP-1034 topUp descriptor does not match stored channel.')
+ }
+ const validatedAdditionalDeposit = uint96(additionalDeposit)
+ if (
+ expected?.additionalDeposit !== undefined &&
+ validatedAdditionalDeposit !== expected.additionalDeposit
+ )
+ throw new Error('TIP-1034 topUp deposit does not match credential.')
+ return {
+ descriptor: descriptor as Channel.ChannelDescriptor,
+ additionalDeposit: validatedAdditionalDeposit,
+ }
+}
+
+/** Builds and validates a descriptor from an accepted open call and event expiring nonce hash. */
+export function descriptorFromOpen(parameters: {
+ chainId: number
+ escrow?: Address | undefined
+ expiringNonceHash: Hex
+ payer: Address
+ open: ReturnType
+ channelId?: Hex | undefined
+}): Channel.ChannelDescriptor {
+ const descriptor = {
+ authorizedSigner: parameters.open.authorizedSigner,
+ expiringNonceHash: parameters.expiringNonceHash,
+ operator: parameters.open.operator,
+ payee: parameters.open.payee,
+ payer: parameters.payer,
+ salt: parameters.open.salt,
+ token: parameters.open.token,
+ } satisfies Channel.ChannelDescriptor
+ if (parameters.channelId) {
+ const computed = Channel.computeId({
+ ...descriptor,
+ chainId: parameters.chainId,
+ escrow: parameters.escrow ?? tip20ChannelEscrow,
+ })
+ if (computed.toLowerCase() !== parameters.channelId.toLowerCase())
+ throw new Error('TIP-1034 ChannelOpened channelId does not match descriptor.')
+ }
+ return descriptor
+}
diff --git a/src/tempo/precompile/server/Session.integration.test.ts b/src/tempo/precompile/server/Session.integration.test.ts
new file mode 100644
index 00000000..727e6a7f
--- /dev/null
+++ b/src/tempo/precompile/server/Session.integration.test.ts
@@ -0,0 +1,486 @@
+import { Hex } from 'ox'
+import { encodeFunctionData, parseEventLogs, zeroAddress } from 'viem'
+import { sendTransaction, waitForTransactionReceipt } from 'viem/actions'
+import { describe, expect, test } from 'vp/test'
+import { nodeEnv } from '~test/config.js'
+import { accounts, asset, chain, client } from '~test/tempo/viem.js'
+
+import * as Store from '../../../Store.js'
+import * as ChannelStore from '../../session/ChannelStore.js'
+import { getChannelState } from '../Chain.js'
+import * as Channel from '../Channel.js'
+import {
+ createClosePayload,
+ createOpenPayload,
+ createTopUpPayload,
+ createVoucherPayload,
+} from '../client/ChannelOps.js'
+import { tip20ChannelEscrow } from '../Constants.js'
+import { escrowAbi } from '../escrow.abi.js'
+import { uint96 } from '../Types.js'
+import { session, settle } from './Session.js'
+
+const isPrecompileTestnet = nodeEnv === 'localnet' || nodeEnv === 'devnet'
+const payer = accounts[2]
+const payee = accounts[0]
+const feePayer = accounts[1]
+
+async function sendPrecompileCall(data: Hex.Hex, account = payer) {
+ const hash = await sendTransaction(client, {
+ account,
+ chain,
+ to: tip20ChannelEscrow,
+ data,
+ gasPrice: 30_000_000_000n,
+ })
+ const receipt = await waitForTransactionReceipt(client, { hash })
+ expect(receipt.status).toBe('success')
+ return receipt
+}
+
+function getSingleEvent(receipt: { logs: readonly unknown[] }, name: string) {
+ const logs = parseEventLogs({
+ abi: escrowAbi,
+ eventName: name as never,
+ logs: receipt.logs as never,
+ })
+ expect(logs).toHaveLength(1)
+ return logs[0] as unknown as { args: Record }
+}
+
+async function openRealChannel(deposit = 1_000n) {
+ const salt = Hex.random(32)
+ const receipt = await sendPrecompileCall(
+ encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'open',
+ args: [payee.address, zeroAddress, asset, uint96(deposit), salt, payer.address],
+ }),
+ )
+ const opened = getSingleEvent(receipt, 'ChannelOpened')
+ const descriptor = {
+ payer: payer.address,
+ payee: payee.address,
+ operator: zeroAddress,
+ token: asset,
+ salt,
+ authorizedSigner: payer.address,
+ expiringNonceHash: opened.args.expiringNonceHash as Hex.Hex,
+ } satisfies Channel.ChannelDescriptor
+ const channelId = opened.args.channelId as Hex.Hex
+ expect(Channel.computeId({ ...descriptor, chainId: chain.id, escrow: tip20ChannelEscrow })).toBe(
+ channelId,
+ )
+ return { channelId, descriptor, deposit: uint96(deposit) }
+}
+
+describe.runIf(isPrecompileTestnet)('precompile server session chain integration', () => {
+ test('broadcasts and verifies a real precompile open credential', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const method = session({
+ amount: '100',
+ chainId: chain.id,
+ currency: asset,
+ decimals: 0,
+ recipient: payee.address,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => client,
+ })
+ const payload = await createOpenPayload(client, payer, {
+ chainId: chain.id,
+ deposit: uint96(1_000n),
+ initialAmount: uint96(100n),
+ payee: payee.address,
+ token: asset,
+ })
+ if (payload.action !== 'open') throw new Error('expected open payload')
+
+ const receipt = await method.verify({
+ credential: {
+ challenge: {
+ id: 'chain-open-challenge',
+ realm: 'api.example.com',
+ method: 'tempo',
+ intent: 'session',
+ request: {
+ amount: '100',
+ currency: asset,
+ recipient: payee.address,
+ methodDetails: {
+ chainId: chain.id,
+ escrowContract: tip20ChannelEscrow,
+ channelId: payload.channelId,
+ },
+ },
+ } as never,
+ payload,
+ },
+ request: {
+ amount: '100',
+ currency: asset,
+ recipient: payee.address,
+ methodDetails: {
+ chainId: chain.id,
+ escrowContract: tip20ChannelEscrow,
+ channelId: payload.channelId,
+ },
+ } as never,
+ })
+
+ expect(receipt.reference).toBe(payload.channelId)
+ if (!('txHash' in receipt)) throw new Error('expected open txHash')
+ const txReceipt = await waitForTransactionReceipt(client, { hash: receipt.txHash as Hex.Hex })
+ const opened = getSingleEvent(txReceipt, 'ChannelOpened')
+ expect(opened.args.channelId).toBe(payload.channelId)
+ expect(opened.args.expiringNonceHash).toBe(payload.descriptor.expiringNonceHash)
+ const state = await getChannelState(client, payload.channelId, tip20ChannelEscrow)
+ expect(state.deposit).toBe(1_000n)
+ const stored = await store.getChannel(payload.channelId)
+ expect(stored?.backend).toBe('precompile')
+ expect(stored?.highestVoucherAmount).toBe(100n)
+ })
+
+ test('broadcasts top-up credentials and stores event-backed deposit', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const method = session({
+ amount: '100',
+ chainId: chain.id,
+ currency: asset,
+ decimals: 0,
+ recipient: payee.address,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => client,
+ })
+ const openPayload = await createOpenPayload(client, payer, {
+ chainId: chain.id,
+ deposit: uint96(500n),
+ initialAmount: uint96(100n),
+ payee: payee.address,
+ token: asset,
+ })
+ if (openPayload.action !== 'open') throw new Error('expected open payload')
+ await method.verify({
+ credential: {
+ challenge: {
+ id: 'chain-topup-open',
+ request: { currency: asset, recipient: payee.address },
+ } as never,
+ payload: openPayload,
+ },
+ request: {
+ methodDetails: {
+ chainId: chain.id,
+ escrowContract: tip20ChannelEscrow,
+ channelId: openPayload.channelId,
+ },
+ } as never,
+ })
+
+ const topUpPayload = await createTopUpPayload(
+ client,
+ payer,
+ openPayload.descriptor,
+ uint96(700n),
+ chain.id,
+ )
+ const receipt = await method.verify({
+ credential: {
+ challenge: {
+ id: 'chain-topup',
+ request: { currency: asset, recipient: payee.address },
+ } as never,
+ payload: topUpPayload,
+ },
+ request: {
+ methodDetails: {
+ chainId: chain.id,
+ escrowContract: tip20ChannelEscrow,
+ channelId: topUpPayload.channelId,
+ },
+ } as never,
+ })
+
+ if (!('txHash' in receipt)) throw new Error('expected topUp txHash')
+ const txReceipt = await waitForTransactionReceipt(client, { hash: receipt.txHash as Hex.Hex })
+ const toppedUp = getSingleEvent(txReceipt, 'TopUp')
+ expect(toppedUp.args.channelId).toBe(openPayload.channelId)
+ expect(toppedUp.args.newDeposit).toBe(1_200n)
+ const state = await getChannelState(client, openPayload.channelId, tip20ChannelEscrow)
+ expect(state.deposit).toBe(1_200n)
+ const stored = await store.getChannel(openPayload.channelId)
+ expect(stored?.deposit).toBe(1_200n)
+ expect(stored?.closeRequestedAt).toBe(0n)
+ })
+
+ test('verifies vouchers and settles against a real precompile channel', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const { channelId, descriptor, deposit } = await openRealChannel(1_000n)
+
+ await store.updateChannel(channelId, () => ({
+ backend: 'precompile',
+ channelId,
+ chainId: chain.id,
+ escrowContract: tip20ChannelEscrow,
+ closeRequestedAt: 0n,
+ payer: descriptor.payer,
+ payee: descriptor.payee,
+ token: descriptor.token,
+ authorizedSigner: descriptor.authorizedSigner,
+ deposit,
+ settledOnChain: 0n,
+ highestVoucherAmount: 0n,
+ highestVoucher: null,
+ spent: 0n,
+ units: 0,
+ finalized: false,
+ createdAt: new Date().toISOString(),
+ descriptor,
+ operator: descriptor.operator,
+ salt: descriptor.salt,
+ expiringNonceHash: descriptor.expiringNonceHash,
+ }))
+
+ const method = session({
+ amount: '100',
+ chainId: chain.id,
+ currency: asset,
+ decimals: 0,
+ recipient: payee.address,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => client,
+ })
+ const payload = await createVoucherPayload(client, payer, descriptor, uint96(300n), chain.id)
+
+ const receipt = await method.verify({
+ credential: {
+ challenge: {
+ id: 'chain-challenge',
+ realm: 'api.example.com',
+ method: 'tempo',
+ intent: 'session',
+ request: {
+ amount: '100',
+ currency: asset,
+ recipient: payee.address,
+ methodDetails: { chainId: chain.id, escrowContract: tip20ChannelEscrow, channelId },
+ },
+ } as never,
+ payload,
+ },
+ request: {
+ amount: '100',
+ currency: asset,
+ recipient: payee.address,
+ methodDetails: { chainId: chain.id, escrowContract: tip20ChannelEscrow, channelId },
+ } as never,
+ })
+ expect(receipt.reference).toBe(channelId)
+
+ const txHash = await settle(store, client, channelId)
+ const settleReceipt = await waitForTransactionReceipt(client, { hash: txHash })
+ expect(settleReceipt.status).toBe('success')
+ const settled = getSingleEvent(settleReceipt, 'Settled')
+ expect(settled.args.channelId).toBe(channelId)
+ expect(settled.args.newSettled).toBe(300n)
+
+ const state = await getChannelState(client, channelId, tip20ChannelEscrow)
+ expect(state.settled).toBe(300n)
+ const settledStore = await store.getChannel(channelId)
+ expect(settledStore?.settledOnChain).toBe(300n)
+ })
+
+ test('settles a real precompile channel with fee-payer sponsorship', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const { channelId, descriptor, deposit } = await openRealChannel(1_000n)
+
+ const voucher = await createVoucherPayload(client, payer, descriptor, uint96(250n), chain.id)
+ if (voucher.action !== 'voucher') throw new Error('expected voucher payload')
+ await store.updateChannel(channelId, () => ({
+ backend: 'precompile',
+ channelId,
+ chainId: chain.id,
+ escrowContract: tip20ChannelEscrow,
+ closeRequestedAt: 0n,
+ payer: descriptor.payer,
+ payee: descriptor.payee,
+ token: descriptor.token,
+ authorizedSigner: descriptor.authorizedSigner,
+ deposit,
+ settledOnChain: 0n,
+ highestVoucherAmount: 250n,
+ highestVoucher: {
+ channelId,
+ cumulativeAmount: 250n,
+ signature: voucher.signature,
+ },
+ spent: 0n,
+ units: 0,
+ finalized: false,
+ createdAt: new Date().toISOString(),
+ descriptor,
+ operator: descriptor.operator,
+ salt: descriptor.salt,
+ expiringNonceHash: descriptor.expiringNonceHash,
+ }))
+
+ const txHash = await settle(store, client, channelId, {
+ account: payee,
+ feePayer,
+ feeToken: asset,
+ })
+ const receipt = await waitForTransactionReceipt(client, { hash: txHash })
+ expect(receipt.status).toBe('success')
+ const settled = getSingleEvent(receipt, 'Settled')
+ expect(settled.args.channelId).toBe(channelId)
+ expect(settled.args.newSettled).toBe(250n)
+ })
+
+ test('closes a real precompile channel with fee-payer sponsorship', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const { channelId, descriptor, deposit } = await openRealChannel(1_000n)
+ await store.updateChannel(channelId, () => ({
+ backend: 'precompile',
+ channelId,
+ chainId: chain.id,
+ escrowContract: tip20ChannelEscrow,
+ closeRequestedAt: 0n,
+ payer: descriptor.payer,
+ payee: descriptor.payee,
+ token: descriptor.token,
+ authorizedSigner: descriptor.authorizedSigner,
+ deposit,
+ settledOnChain: 0n,
+ highestVoucherAmount: 300n,
+ highestVoucher: null,
+ spent: 300n,
+ units: 3,
+ finalized: false,
+ createdAt: new Date().toISOString(),
+ descriptor,
+ operator: descriptor.operator,
+ salt: descriptor.salt,
+ expiringNonceHash: descriptor.expiringNonceHash,
+ }))
+ const method = session({
+ account: payee,
+ amount: '100',
+ chainId: chain.id,
+ currency: asset,
+ decimals: 0,
+ feePayer,
+ feeToken: asset,
+ recipient: payee.address,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => client,
+ })
+ const payload = await createClosePayload(client, payer, descriptor, uint96(300n), chain.id)
+
+ const receipt = await method.verify({
+ credential: {
+ challenge: {
+ id: 'chain-sponsored-close',
+ request: { currency: asset, recipient: payee.address },
+ } as never,
+ payload,
+ },
+ request: {
+ methodDetails: { chainId: chain.id, escrowContract: tip20ChannelEscrow, channelId },
+ } as never,
+ })
+ if (!('txHash' in receipt)) throw new Error('expected sponsored close txHash')
+ const closeReceipt = await waitForTransactionReceipt(client, {
+ hash: receipt.txHash as Hex.Hex,
+ })
+ expect(closeReceipt.status).toBe('success')
+ const closed = getSingleEvent(closeReceipt, 'ChannelClosed')
+ expect(closed.args.channelId).toBe(channelId)
+ expect(closed.args.settledToPayee).toBe(300n)
+ })
+
+ test('closes a real precompile channel only after a successful close receipt', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const { channelId, descriptor, deposit } = await openRealChannel(1_000n)
+
+ await store.updateChannel(channelId, () => ({
+ backend: 'precompile',
+ channelId,
+ chainId: chain.id,
+ escrowContract: tip20ChannelEscrow,
+ closeRequestedAt: 0n,
+ payer: descriptor.payer,
+ payee: descriptor.payee,
+ token: descriptor.token,
+ authorizedSigner: descriptor.authorizedSigner,
+ deposit,
+ settledOnChain: 0n,
+ highestVoucherAmount: 300n,
+ highestVoucher: null,
+ spent: 300n,
+ units: 3,
+ finalized: false,
+ createdAt: new Date().toISOString(),
+ descriptor,
+ operator: descriptor.operator,
+ salt: descriptor.salt,
+ expiringNonceHash: descriptor.expiringNonceHash,
+ }))
+
+ const method = session({
+ amount: '100',
+ chainId: chain.id,
+ currency: asset,
+ decimals: 0,
+ recipient: payee.address,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => client,
+ })
+ const payload = await createClosePayload(client, payer, descriptor, uint96(300n), chain.id)
+
+ const receipt = await method.verify({
+ credential: {
+ challenge: {
+ id: 'chain-close',
+ realm: 'api.example.com',
+ method: 'tempo',
+ intent: 'session',
+ request: {
+ amount: '100',
+ currency: asset,
+ recipient: payee.address,
+ methodDetails: { chainId: chain.id, escrowContract: tip20ChannelEscrow, channelId },
+ },
+ } as never,
+ payload,
+ },
+ request: {
+ amount: '100',
+ currency: asset,
+ recipient: payee.address,
+ methodDetails: { chainId: chain.id, escrowContract: tip20ChannelEscrow, channelId },
+ } as never,
+ })
+
+ if (!('txHash' in receipt)) throw new Error('expected close txHash')
+ const closeReceipt = await waitForTransactionReceipt(client, {
+ hash: receipt.txHash as Hex.Hex,
+ })
+ expect(closeReceipt.status).toBe('success')
+ const closed = getSingleEvent(closeReceipt, 'ChannelClosed')
+ expect(closed.args.channelId).toBe(channelId)
+ expect(closed.args.settledToPayee).toBe(300n)
+ const stored = await store.getChannel(channelId)
+ expect(stored?.finalized).toBe(true)
+ expect(stored?.settledOnChain).toBe(300n)
+ })
+})
diff --git a/src/tempo/precompile/server/Session.test.ts b/src/tempo/precompile/server/Session.test.ts
new file mode 100644
index 00000000..b7b7e224
--- /dev/null
+++ b/src/tempo/precompile/server/Session.test.ts
@@ -0,0 +1,2356 @@
+import { Challenge, Credential } from 'mppx'
+import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
+import {
+ type Address,
+ createClient,
+ custom,
+ encodeAbiParameters,
+ encodeEventTopics,
+ encodeFunctionData,
+ encodeFunctionResult,
+ type Hex,
+ zeroAddress,
+} from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { Transaction } from 'viem/tempo'
+import { describe, expect, test } from 'vp/test'
+
+import * as Store from '../../../Store.js'
+import * as ChannelStore from '../../session/ChannelStore.js'
+import { deserializeSessionReceipt } from '../../session/Receipt.js'
+import type { SessionReceipt } from '../../session/Types.js'
+import * as Channel from '../Channel.js'
+import * as ClientOps from '../client/ChannelOps.js'
+import { tip20ChannelEscrow } from '../Constants.js'
+import { escrowAbi } from '../escrow.abi.js'
+import { sessionManager as precompileSessionManager } from '../session/SessionManager.js'
+import type { SessionCredentialPayload } from '../Types.js'
+import * as Types from '../Types.js'
+import * as Voucher from '../Voucher.js'
+import { charge, session } from './Session.js'
+
+const payer = privateKeyToAccount(
+ '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
+)
+const wrongPayer = privateKeyToAccount(
+ '0x59c6995e998f97a5a0044966f094538a009d74290f5811cfba6a6b4d238ff944',
+)
+const chainId = 42431
+const payee = '0x0000000000000000000000000000000000000002' as Address
+const token = '0x0000000000000000000000000000000000000003' as Address
+const wrongTarget = '0x0000000000000000000000000000000000000004' as Address
+
+type RpcCall = { method: string; params?: unknown }
+type ChainState = { settled: bigint; deposit: bigint; closeRequestedAt: number }
+
+function createSigningClient(account = payer) {
+ return createClient({
+ account,
+ chain: { id: chainId } as never,
+ transport: custom({
+ async request(args) {
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
+ if (args.method === 'eth_getTransactionCount') return '0x0'
+ if (args.method === 'eth_estimateGas') return '0x5208'
+ if (args.method === 'eth_maxPriorityFeePerGas') return '0x1'
+ if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
+ throw new Error(`unexpected signing rpc request: ${args.method}`)
+ },
+ }),
+ })
+}
+
+function createServerClient(
+ calls: RpcCall[] = [],
+ account: typeof payer | null = payer,
+ _eventChannelId: Hex = `0x${'00'.repeat(32)}` as Hex,
+ options: {
+ descriptor?: Channel.ChannelDescriptor
+ receipt?: Record
+ state?: ChainState
+ } = {},
+) {
+ return createClient({
+ ...(account ? { account } : {}),
+ chain: { id: chainId } as never,
+ transport: custom({
+ async request(args) {
+ calls.push(args)
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
+ if (args.method === 'eth_getTransactionCount') return '0x0'
+ if (args.method === 'eth_estimateGas') return '0x5208'
+ if (args.method === 'eth_maxPriorityFeePerGas') return '0x1'
+ if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
+ if (args.method === 'eth_sendRawTransaction') return `0x${'aa'.repeat(32)}`
+ if (args.method === 'eth_getTransactionReceipt') return options.receipt ?? null
+ if (args.method === 'eth_sendTransaction') return `0x${'bb'.repeat(32)}`
+ if (args.method === 'eth_call') {
+ const state = options.state ?? { settled: 100n, deposit: 1_000n, closeRequestedAt: 0 }
+ const data = (args.params as [{ data?: Hex }])[0].data
+ const getChannelSelector = options.descriptor
+ ? encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'getChannel',
+ args: [options.descriptor],
+ }).slice(0, 10)
+ : undefined
+ if (data && getChannelSelector && data.slice(0, 10) === getChannelSelector)
+ return encodeFunctionResult({
+ abi: escrowAbi,
+ functionName: 'getChannel',
+ result: { descriptor: options.descriptor!, state },
+ })
+ return encodeFunctionResult({
+ abi: escrowAbi,
+ functionName: 'getChannelState',
+ result: state,
+ })
+ }
+ throw new Error(`unexpected rpc request: ${args.method}`)
+ },
+ }),
+ })
+}
+
+function createStateClient(
+ account: typeof payer | null = payer,
+ state: ChainState = {
+ settled: 0n,
+ deposit: 1_000n,
+ closeRequestedAt: 0,
+ },
+) {
+ return createClient({
+ ...(account ? { account } : {}),
+ chain: { id: chainId } as never,
+ transport: custom({
+ async request(args) {
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
+ if (args.method === 'eth_call')
+ return encodeFunctionResult({
+ abi: escrowAbi,
+ functionName: 'getChannelState',
+ result: state,
+ })
+ if (args.method === 'eth_getTransactionCount') return '0x0'
+ if (args.method === 'eth_estimateGas') return '0x5208'
+ if (args.method === 'eth_maxPriorityFeePerGas') return '0x1'
+ if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
+ throw new Error(`unexpected rpc request: ${args.method}`)
+ },
+ }),
+ })
+}
+
+function createServer(parameters: Partial = {}) {
+ const rawStore = Store.memory()
+ const rpcCalls: RpcCall[] = []
+ const serverClient = createServerClient(rpcCalls)
+ const method = session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => serverClient,
+ ...parameters,
+ })
+ return { method, store: ChannelStore.fromStore(rawStore as never), rpcCalls }
+}
+
+function makeChallenge(channelId?: Hex): any {
+ return {
+ id: 'challenge-id',
+ realm: 'api.example.com',
+ method: 'tempo',
+ intent: 'session',
+ request: {
+ amount: '100',
+ currency: token,
+ recipient: payee,
+ methodDetails: {
+ chainId,
+ escrowContract: tip20ChannelEscrow,
+ ...(channelId && { channelId }),
+ },
+ },
+ }
+}
+
+function makeRequest(channelId?: Hex) {
+ return makeChallenge(channelId).request
+}
+
+let saltCounter = 0
+
+async function createOpenPayload(
+ parameters: {
+ deposit?: bigint | undefined
+ initialAmount?: bigint | undefined
+ escrow?: Address | undefined
+ account?: typeof payer | undefined
+ operator?: Address | undefined
+ authorizedSigner?: Address | undefined
+ } = {},
+): Promise> {
+ const account = parameters.account ?? payer
+ const escrow = parameters.escrow ?? tip20ChannelEscrow
+ const initialAmount = Types.uint96(parameters.initialAmount ?? 100n)
+ const deposit = Types.uint96(parameters.deposit ?? 1_000n)
+ const salt = `0x${(++saltCounter).toString(16).padStart(64, '0')}` as Hex
+ const operator = parameters.operator ?? zeroAddress
+ const authorizedSigner = parameters.authorizedSigner ?? account.address
+ const data = encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'open',
+ args: [payee, operator, token, deposit, salt, authorizedSigner],
+ })
+ const signingClient = createSigningClient(account)
+ const transaction = (await Transaction.serialize({
+ chainId,
+ calls: [{ to: escrow, data }],
+ feeToken: token,
+ nonce: 0,
+ })) as Hex
+ const expiringNonceHash = Channel.computeExpiringNonceHash(
+ Transaction.deserialize(
+ transaction as Transaction.TransactionSerializedTempo,
+ ) as Channel.ExpiringNonceTransaction,
+ { sender: account.address },
+ )
+ const descriptor = {
+ payer: account.address,
+ payee,
+ operator,
+ token,
+ salt,
+ authorizedSigner,
+ expiringNonceHash,
+ } satisfies Channel.ChannelDescriptor
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow })
+ const signature = await Voucher.signVoucher(
+ signingClient,
+ account,
+ { channelId, cumulativeAmount: initialAmount },
+ escrow,
+ chainId,
+ )
+ return {
+ action: 'open',
+ type: 'transaction',
+ channelId,
+ transaction,
+ signature,
+ descriptor,
+ cumulativeAmount: initialAmount.toString(),
+ authorizedSigner: descriptor.authorizedSigner,
+ }
+}
+
+function transactionReceipt(logs: readonly Record[]) {
+ return {
+ blockHash: `0x${'01'.repeat(32)}`,
+ blockNumber: '0x1',
+ contractAddress: null,
+ cumulativeGasUsed: '0x1',
+ effectiveGasPrice: '0x1',
+ from: payer.address,
+ gasUsed: '0x1',
+ logs,
+ logsBloom: `0x${'00'.repeat(256)}`,
+ status: '0x1',
+ to: tip20ChannelEscrow,
+ transactionHash: `0x${'aa'.repeat(32)}`,
+ transactionIndex: '0x0',
+ type: '0x76',
+ }
+}
+
+function openedLog(
+ payload: Extract,
+ deposit = 1_000n,
+) {
+ return {
+ address: tip20ChannelEscrow,
+ data: encodeAbiParameters(
+ [
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'bytes32' },
+ { type: 'bytes32' },
+ { type: 'uint96' },
+ ],
+ [
+ payload.descriptor.operator,
+ payload.descriptor.token,
+ payload.descriptor.authorizedSigner,
+ payload.descriptor.salt,
+ payload.descriptor.expiringNonceHash,
+ deposit,
+ ],
+ ),
+ topics: encodeEventTopics({
+ abi: escrowAbi,
+ eventName: 'ChannelOpened',
+ args: {
+ channelId: payload.channelId,
+ payer: payload.descriptor.payer,
+ payee: payload.descriptor.payee,
+ },
+ }),
+ }
+}
+
+function settledLog(channelId: Hex, newSettled: bigint) {
+ return {
+ address: tip20ChannelEscrow,
+ data: encodeAbiParameters(
+ [{ type: 'uint96' }, { type: 'uint96' }, { type: 'uint96' }],
+ [newSettled, newSettled, newSettled],
+ ),
+ topics: encodeEventTopics({
+ abi: escrowAbi,
+ eventName: 'Settled',
+ args: { channelId, payer: payer.address, payee: payer.address },
+ }),
+ }
+}
+
+function closedLog(channelId: Hex, settledToPayee: bigint, refundedToPayer: bigint) {
+ return {
+ address: tip20ChannelEscrow,
+ data: encodeAbiParameters(
+ [{ type: 'uint96' }, { type: 'uint96' }],
+ [settledToPayee, refundedToPayer],
+ ),
+ topics: encodeEventTopics({
+ abi: escrowAbi,
+ eventName: 'ChannelClosed',
+ args: { channelId, payer: payer.address, payee: payer.address },
+ }),
+ }
+}
+
+function topUpLog(
+ payload: Extract,
+ newDeposit: bigint,
+) {
+ return {
+ address: tip20ChannelEscrow,
+ data: encodeAbiParameters([{ type: 'uint96' }, { type: 'uint96' }], [1_000n, newDeposit]),
+ topics: encodeEventTopics({
+ abi: escrowAbi,
+ eventName: 'TopUp',
+ args: { channelId: payload.channelId, payer: payer.address, payee },
+ }),
+ }
+}
+
+async function createTopUpPayload(
+ descriptor: Channel.ChannelDescriptor,
+ additionalDeposit = 500n,
+): Promise> {
+ const data = encodeFunctionData({
+ abi: escrowAbi,
+ functionName: 'topUp',
+ args: [descriptor, Types.uint96(additionalDeposit)],
+ })
+ const transaction = (await Transaction.serialize({
+ chainId,
+ calls: [{ to: tip20ChannelEscrow, data }],
+ feeToken: token,
+ nonce: 0,
+ })) as Hex
+ const channelId = Channel.computeId({ ...descriptor, chainId, escrow: tip20ChannelEscrow })
+ return {
+ action: 'topUp',
+ type: 'transaction',
+ channelId,
+ descriptor,
+ transaction,
+ additionalDeposit: additionalDeposit.toString(),
+ }
+}
+
+async function persistPrecompileChannel(
+ store: ChannelStore.ChannelStore,
+ payload: Extract,
+ overrides: Partial = {},
+) {
+ await store.updateChannel(payload.channelId, () => ({
+ backend: 'precompile',
+ channelId: payload.channelId,
+ chainId,
+ escrowContract: tip20ChannelEscrow,
+ closeRequestedAt: 0n,
+ payer: payload.descriptor.payer,
+ payee,
+ token,
+ authorizedSigner: payload.descriptor.authorizedSigner,
+ deposit: 1_000n,
+ settledOnChain: 0n,
+ highestVoucherAmount: BigInt(payload.cumulativeAmount),
+ highestVoucher: {
+ channelId: payload.channelId,
+ cumulativeAmount: BigInt(payload.cumulativeAmount),
+ signature: payload.signature,
+ },
+ spent: 0n,
+ units: 0,
+ finalized: false,
+ createdAt: new Date(0).toISOString(),
+ descriptor: payload.descriptor,
+ operator: payload.descriptor.operator,
+ salt: payload.descriptor.salt,
+ expiringNonceHash: payload.descriptor.expiringNonceHash,
+ ...overrides,
+ }))
+}
+
+describe('precompile server session unit guardrails', () => {
+ test('request normalizes fee-payer to boolean for challenge issuance and account for verification', async () => {
+ const { method } = createServer({ feePayer: wrongPayer })
+
+ const challengeRequest = await method.request!({
+ credential: null,
+ request: {
+ amount: '1',
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ unitType: 'request',
+ },
+ } as never)
+ expect(challengeRequest.feePayer).toBe(true)
+
+ const verificationRequest = await method.request!({
+ credential: { challenge: {}, payload: {} } as never,
+ request: {
+ amount: '1',
+ currency: token,
+ decimals: 0,
+ feePayer: payer,
+ recipient: payee,
+ unitType: 'request',
+ },
+ } as never)
+ expect(verificationRequest.feePayer).toBe(payer)
+ })
+
+ test('request allows callers to explicitly disable precompile fee-payer', async () => {
+ const { method } = createServer({ feePayer: wrongPayer })
+
+ const challengeRequest = await method.request!({
+ credential: null,
+ request: {
+ amount: '1',
+ currency: token,
+ decimals: 0,
+ feePayer: false,
+ recipient: payee,
+ unitType: 'request',
+ },
+ } as never)
+ expect(challengeRequest.feePayer).toBeUndefined()
+
+ const verificationRequest = await method.request!({
+ credential: { challenge: {}, payload: {} } as never,
+ request: {
+ amount: '1',
+ currency: token,
+ decimals: 0,
+ feePayer: false,
+ recipient: payee,
+ unitType: 'request',
+ },
+ } as never)
+ expect(verificationRequest.feePayer).toBe(false)
+ })
+
+ test('request throws when resolved precompile client chain mismatches requested chain', async () => {
+ const { method } = createServer({ chainId: 1 })
+
+ await expect(
+ method.request!({
+ credential: null,
+ request: {
+ amount: '1',
+ chainId: 1,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ unitType: 'request',
+ },
+ } as never),
+ ).rejects.toThrow('Client not configured with chainId 1.')
+ })
+
+ test('rejects open transactions targeting the wrong address', async () => {
+ const { method } = createServer()
+ const payload = await createOpenPayload({ escrow: wrongTarget })
+
+ await expect(
+ method.verify({
+ credential: { challenge: makeChallenge(payload.channelId), payload },
+ request: makeRequest(payload.channelId) as never,
+ }),
+ ).rejects.toThrow(/descriptor does not match channelId|wrong address/)
+ })
+
+ test('rejects smuggled extra calls in open transactions', async () => {
+ const { method } = createServer()
+ const payload = await createOpenPayload()
+ const tampered = await createOpenPayload()
+
+ // Reuse a valid descriptor/signature, but submit a transaction whose calls
+ // do not correspond to that descriptor. This exercises the same one-call /
+ // smuggling guard as legacy server session tests without requiring a live
+ // chain-backed precompile.
+ const smuggled = { ...payload, transaction: tampered.transaction }
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(payload.channelId),
+ payload: smuggled,
+ },
+ request: makeRequest(payload.channelId) as never,
+ }),
+ ).rejects.toThrow(/does not match/)
+ })
+
+ test('rejects descriptors that do not match the challenge channel ID', async () => {
+ const { method } = createServer()
+ const payload = await createOpenPayload()
+ const badDescriptor = {
+ ...payload.descriptor,
+ token: '0x0000000000000000000000000000000000000005' as Address,
+ }
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(payload.channelId),
+ payload: { ...payload, descriptor: badDescriptor },
+ },
+ request: makeRequest(payload.channelId) as never,
+ }),
+ ).rejects.toThrow(/descriptor does not match channelId/)
+ })
+
+ test('rejects invalid initial voucher signatures', async () => {
+ const { method } = createServer()
+ const payload = await createOpenPayload()
+ const badSignaturePayload = {
+ ...payload,
+ signature: (await createOpenPayload({ account: wrongPayer })).signature,
+ }
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(payload.channelId),
+ payload: badSignaturePayload,
+ },
+ request: makeRequest(payload.channelId) as never,
+ }),
+ ).rejects.toThrow(/invalid voucher signature/)
+ })
+
+ test('rejects missing precompile descriptors with a verification error', async () => {
+ const { method } = createServer()
+ const payload = await createOpenPayload()
+ const { descriptor: _descriptor, ...payloadWithoutDescriptor } = payload
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(payload.channelId),
+ payload: payloadWithoutDescriptor,
+ },
+ request: makeRequest(payload.channelId) as never,
+ }),
+ ).rejects.toThrow(/descriptor required for precompile session action/)
+ })
+
+ test('rejects uint96 overflow in credential amount parsing', async () => {
+ const { method } = createServer()
+ const payload = await createOpenPayload()
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(payload.channelId),
+ payload: { ...payload, cumulativeAmount: (1n << 96n).toString() },
+ },
+ request: makeRequest(payload.channelId) as never,
+ }),
+ ).rejects.toThrow(/outside uint96 bounds/)
+ })
+
+ test('rejects settle when no account is available', async () => {
+ const { store } = createServer()
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload)
+
+ const { settle } = await import('./Session.js')
+ await expect(
+ settle(store, createServerClient([], null), openPayload.channelId),
+ ).rejects.toThrow(/no account available/)
+ })
+
+ test('rejects settle when sender is not the channel payee or operator', async () => {
+ const { store } = createServer()
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload)
+
+ const { settle } = await import('./Session.js')
+ await expect(
+ settle(store, createServerClient([], wrongPayer), openPayload.channelId),
+ ).rejects.toThrow(/tx sender .* is not the channel payee/)
+ })
+
+ test('accepts settle sender matching a nonzero precompile operator', async () => {
+ const { store } = createServer()
+ const openPayload = await createOpenPayload({
+ operator: wrongPayer.address,
+ })
+ await persistPrecompileChannel(store, openPayload)
+
+ const { settle } = await import('./Session.js')
+ const client = createClient({
+ account: wrongPayer,
+ chain: { id: chainId } as never,
+ transport: custom({
+ async request(args) {
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
+ throw new Error(`unexpected rpc request: ${args.method}`)
+ },
+ }),
+ })
+ await expect(settle(store, client, openPayload.channelId)).rejects.toThrow(
+ /eth_getTransactionCount/,
+ )
+ })
+
+ test('precompile settle fee payer options still enforce payee sender policy', async () => {
+ const { store } = createServer()
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload)
+
+ const { settle } = await import('./Session.js')
+ await expect(
+ settle(store, createServerClient([], payer), openPayload.channelId, {
+ feePayer: wrongPayer,
+ }),
+ ).rejects.toThrow(/tx sender .* is not the channel payee/)
+ })
+
+ test('accepts precompile settle fee token options', async () => {
+ const { store } = createServer()
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload, {
+ payee: payer.address,
+ })
+ const client = createClient({
+ account: payer,
+ chain: { id: chainId } as never,
+ transport: custom({
+ async request(args) {
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
+ if (args.method === 'eth_sendTransaction') throw new Error('sent fee-token settle')
+ throw new Error(`unexpected rpc request: ${args.method}`)
+ },
+ }),
+ })
+
+ const { settle } = await import('./Session.js')
+ await expect(
+ settle(store, client, openPayload.channelId, {
+ feeToken: token,
+ }),
+ ).rejects.toThrow(/eth_getTransactionCount/)
+ })
+
+ test('accepts settle account override matching the channel payee', async () => {
+ const { store } = createServer()
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload, {
+ payee: wrongPayer.address,
+ })
+ const client = createClient({
+ account: payer,
+ chain: { id: chainId } as never,
+ transport: custom({
+ async request(args) {
+ if (args.method === 'eth_sendTransaction') throw new Error('sent settle transaction')
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
+ throw new Error(`unexpected rpc request: ${args.method}`)
+ },
+ }),
+ })
+
+ const { settle } = await import('./Session.js')
+ await expect(
+ settle(store, client, openPayload.channelId, {
+ account: wrongPayer,
+ }),
+ ).rejects.toThrow(/eth_getTransactionCount/)
+ })
+
+ test('rejects precompile settle fee-payer policy violations', async () => {
+ const { store } = createServer()
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload, {
+ payee: payer.address,
+ })
+
+ const { settle } = await import('./Session.js')
+ await expect(
+ settle(store, createServerClient([], payer), openPayload.channelId, {
+ feePayer: wrongPayer,
+ feePayerPolicy: { maxGas: 1n },
+ feeToken: token,
+ }),
+ ).rejects.toThrow(/fee-payer policy maxGas exceeded/)
+ })
+
+ test('rejects close voucher below local spent', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload, {
+ payee: payer.address,
+ spent: 150n,
+ })
+ const method = session({
+ account: payer,
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => createStateClient(payer),
+ })
+ const payload = await ClientOps.createClosePayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(100n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/close voucher amount must be >= 150 \(spent\)/)
+ })
+
+ test('rejects close voucher below on-chain settled', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload, {
+ payee: payer.address,
+ })
+ const method = session({
+ account: payer,
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createStateClient(payer, {
+ settled: 100n,
+ deposit: 1_000n,
+ closeRequestedAt: 0,
+ }),
+ })
+ const payload = await ClientOps.createClosePayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(99n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/close voucher amount must be >= 100 \(on-chain settled\)/)
+ })
+
+ test('rejects close capture exceeding on-chain precompile deposit', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload, {
+ payee: payer.address,
+ spent: 100n,
+ })
+ const method = session({
+ account: payer,
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createStateClient(payer, {
+ settled: 0n,
+ deposit: 99n,
+ closeRequestedAt: 0,
+ }),
+ })
+ const payload = await ClientOps.createClosePayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(100n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/close capture amount exceeds on-chain deposit/)
+ })
+
+ test('rejects close for locally finalized and pending precompile channels', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload()
+ const payload = await ClientOps.createClosePayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(100n),
+ chainId,
+ )
+ const method = session({
+ account: payer,
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => createStateClient(payer),
+ })
+
+ await persistPrecompileChannel(store, openPayload, {
+ finalized: true,
+ payee: payer.address,
+ })
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/channel is already finalized/)
+
+ await persistPrecompileChannel(store, openPayload, {
+ closeRequestedAt: 1n,
+ payee: payer.address,
+ })
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/channel has a pending close request/)
+ })
+
+ test('accepts valid precompile open with voucher and stores state', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ const method = session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createServerClient([], payer, openPayload.channelId, {
+ descriptor: openPayload.descriptor,
+ receipt: transactionReceipt([openedLog(openPayload)]),
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
+ }),
+ })
+
+ const receipt = (await method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: openPayload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ })) as SessionReceipt
+
+ const stored = await store.getChannel(openPayload.channelId)
+ expect(receipt.acceptedCumulative).toBe('100')
+ expect(stored?.backend).toBe('precompile')
+ expect(stored?.deposit).toBe(1_000n)
+ expect(stored?.highestVoucherAmount).toBe(100n)
+ if (!stored || !ChannelStore.isPrecompileState(stored))
+ throw new Error('expected precompile state')
+ expect(stored.descriptor).toEqual(openPayload.descriptor)
+ })
+
+ test('reopening existing precompile channel with higher voucher updates highest voucher only', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, {
+ highestVoucherAmount: 100n,
+ spent: 75n,
+ units: 3,
+ })
+ const reopenPayload = { ...openPayload, cumulativeAmount: '250' }
+ reopenPayload.signature = await Voucher.signVoucher(
+ createSigningClient(),
+ payer,
+ { channelId: openPayload.channelId, cumulativeAmount: 250n },
+ tip20ChannelEscrow,
+ chainId,
+ )
+ const method = session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createServerClient([], payer, openPayload.channelId, {
+ descriptor: openPayload.descriptor,
+ receipt: transactionReceipt([openedLog(openPayload)]),
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
+ }),
+ })
+
+ const receipt = (await method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: reopenPayload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ })) as SessionReceipt
+
+ const stored = await store.getChannel(openPayload.channelId)
+ expect(receipt.acceptedCumulative).toBe('250')
+ expect(receipt.spent).toBe('75')
+ expect(receipt.units).toBe(3)
+ expect(stored?.highestVoucherAmount).toBe(250n)
+ expect(stored?.spent).toBe(75n)
+ expect(stored?.units).toBe(3)
+ })
+
+ test('reopening existing precompile channel with same voucher preserves accounting', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, {
+ highestVoucherAmount: 100n,
+ spent: 75n,
+ units: 3,
+ })
+ const method = session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createServerClient([], payer, openPayload.channelId, {
+ descriptor: openPayload.descriptor,
+ receipt: transactionReceipt([openedLog(openPayload)]),
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
+ }),
+ })
+
+ const receipt = (await method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: openPayload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ })) as SessionReceipt
+
+ const stored = await store.getChannel(openPayload.channelId)
+ expect(receipt.acceptedCumulative).toBe('100')
+ expect(receipt.spent).toBe('75')
+ expect(receipt.units).toBe(3)
+ expect(stored?.highestVoucherAmount).toBe(100n)
+ expect(stored?.spent).toBe(75n)
+ expect(stored?.units).toBe(3)
+ })
+
+ test('case-variant precompile channelId does not reset open accounting', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, { spent: 75n, units: 3 })
+ const mixedCaseChannelId = openPayload.channelId.replace(/[a-f]/g, (char) =>
+ char.toUpperCase(),
+ ) as Hex
+ const method = session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createServerClient([], payer, openPayload.channelId, {
+ descriptor: openPayload.descriptor,
+ receipt: transactionReceipt([openedLog(openPayload)]),
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
+ }),
+ })
+
+ const receipt = (await method.verify({
+ credential: {
+ challenge: makeChallenge(mixedCaseChannelId),
+ payload: { ...openPayload, channelId: mixedCaseChannelId },
+ },
+ request: makeRequest(mixedCaseChannelId) as never,
+ })) as SessionReceipt
+
+ const stored = await store.getChannel(openPayload.channelId)
+ expect(receipt.spent).toBe('75')
+ expect(receipt.units).toBe(3)
+ expect(stored?.spent).toBe(75n)
+ expect(stored?.units).toBe(3)
+ })
+
+ test('uses payer as precompile voucher signer when authorized signer is zero', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({
+ authorizedSigner: zeroAddress,
+ initialAmount: 100n,
+ })
+ expect(openPayload.descriptor.authorizedSigner).toBe(zeroAddress)
+ const method = session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createServerClient([], payer, openPayload.channelId, {
+ descriptor: openPayload.descriptor,
+ receipt: transactionReceipt([openedLog(openPayload)]),
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
+ }),
+ })
+
+ const receipt = (await method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: openPayload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ })) as SessionReceipt
+
+ const stored = await store.getChannel(openPayload.channelId)
+ expect(receipt.acceptedCumulative).toBe('100')
+ expect(stored?.authorizedSigner).toBe(payer.address)
+ })
+
+ test('accepts precompile top-up and preserves spent accounting', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, {
+ deposit: 1_000n,
+ spent: 125n,
+ units: 4,
+ })
+ const topUpPayload = await createTopUpPayload(openPayload.descriptor, 500n)
+ const method = session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createServerClient([], payer, topUpPayload.channelId, {
+ receipt: transactionReceipt([topUpLog(topUpPayload, 1_500n)]),
+ state: { settled: 0n, deposit: 1_500n, closeRequestedAt: 0 },
+ }),
+ })
+
+ const receipt = (await method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: topUpPayload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ })) as SessionReceipt
+
+ const stored = await store.getChannel(openPayload.channelId)
+ expect(receipt.spent).toBe('125')
+ expect(receipt.units).toBe(4)
+ expect(stored?.deposit).toBe(1_500n)
+ expect(stored?.spent).toBe(125n)
+ expect(stored?.units).toBe(4)
+ })
+
+ test('rejects precompile top-up when on-chain state has pending close', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, { deposit: 1_000n })
+ const topUpPayload = await createTopUpPayload(openPayload.descriptor, 500n)
+ const method = session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createServerClient([], payer, topUpPayload.channelId, {
+ receipt: transactionReceipt([topUpLog(topUpPayload, 1_500n)]),
+ state: { settled: 0n, deposit: 1_500n, closeRequestedAt: 1 },
+ }),
+ })
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: topUpPayload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/pending close request/)
+ })
+
+ test('rejects precompile top-up on unknown channel', async () => {
+ const { method } = createServer()
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ const topUpPayload = await createTopUpPayload(openPayload.descriptor, 500n)
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: topUpPayload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/channel not found/)
+ })
+
+ test('rejects precompile top-up descriptor mismatches', async () => {
+ const { method, store } = createServer()
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload)
+ const badDescriptor = { ...openPayload.descriptor, payee: wrongTarget }
+ const topUpPayload = await createTopUpPayload(badDescriptor, 500n)
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: { ...topUpPayload, channelId: openPayload.channelId },
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/descriptor does not match/)
+ })
+
+ test('accepts increasing precompile voucher and stores accounting state', async () => {
+ const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, {
+ highestVoucherAmount: 100n,
+ spent: 100n,
+ units: 1,
+ })
+ const voucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(250n),
+ chainId,
+ )
+
+ const receipt = (await method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: voucher,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ })) as SessionReceipt
+
+ const stored = await store.getChannel(openPayload.channelId)
+ expect(receipt.acceptedCumulative).toBe('250')
+ expect(receipt.spent).toBe('100')
+ expect(receipt.units).toBe(1)
+ expect(stored?.highestVoucherAmount).toBe(250n)
+ expect(stored?.spent).toBe(100n)
+ expect(stored?.units).toBe(1)
+ })
+
+ test('accepts exact precompile voucher replay idempotently', async () => {
+ const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ const voucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(250n),
+ chainId,
+ )
+ if (voucher.action !== 'voucher') throw new Error('expected voucher payload')
+ await persistPrecompileChannel(store, openPayload, {
+ highestVoucherAmount: 250n,
+ highestVoucher: {
+ channelId: openPayload.channelId,
+ cumulativeAmount: 250n,
+ signature: voucher.signature,
+ },
+ spent: 250n,
+ units: 2,
+ })
+
+ const receipt = (await method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: voucher,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ })) as SessionReceipt
+
+ const stored = await store.getChannel(openPayload.channelId)
+ expect(receipt.acceptedCumulative).toBe('250')
+ expect(receipt.spent).toBe('250')
+ expect(receipt.units).toBe(2)
+ expect(stored?.highestVoucherAmount).toBe(250n)
+ expect(stored?.units).toBe(2)
+ })
+
+ test('rejects lower precompile voucher replay', async () => {
+ const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, {
+ highestVoucherAmount: 500n,
+ spent: 500n,
+ units: 5,
+ })
+ const voucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(250n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: voucher,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(
+ /strictly greater than highest accepted voucher|non-increasing voucher|voucher replay/,
+ )
+ })
+
+ test('rejects precompile voucher below minVoucherDelta', async () => {
+ const { method, store } = createServer({
+ channelStateTtl: Number.MAX_SAFE_INTEGER,
+ minVoucherDelta: '200',
+ })
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, { highestVoucherAmount: 100n })
+ const voucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(250n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: voucher,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/voucher delta 150 below minimum 200/)
+ })
+
+ test('rejects stale or hijacked precompile voucher signatures', async () => {
+ const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, { highestVoucherAmount: 100n })
+ const signature = await Voucher.signVoucher(
+ createSigningClient(wrongPayer),
+ wrongPayer,
+ { channelId: openPayload.channelId, cumulativeAmount: 250n },
+ tip20ChannelEscrow,
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: {
+ action: 'voucher',
+ channelId: openPayload.channelId,
+ cumulativeAmount: '250',
+ descriptor: openPayload.descriptor,
+ signature,
+ },
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/invalid voucher signature/)
+ })
+
+ test('rejects precompile voucher exceeding deposit', async () => {
+ const { method, store } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, { deposit: 300n })
+ const voucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(350n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: voucher,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/exceeds.*deposit|insufficient channel deposit/)
+ })
+
+ test('rejects precompile voucher when on-chain state has pending close', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, { closeRequestedAt: 0n })
+ const voucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(250n),
+ chainId,
+ )
+ const method = session({
+ amount: '1',
+ chainId,
+ channelStateTtl: 0,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createStateClient(payer, { settled: 0n, deposit: 1_000n, closeRequestedAt: 1 }),
+ })
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: voucher,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/pending close request/)
+ })
+
+ test('rejects precompile voucher when on-chain deposit is zero', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, { deposit: 1_000n })
+ const voucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(250n),
+ chainId,
+ )
+ const method = session({
+ amount: '1',
+ chainId,
+ channelStateTtl: 0,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => createStateClient(payer, { settled: 0n, deposit: 0n, closeRequestedAt: 0 }),
+ })
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: voucher,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/deposit is zero|channel deposit is zero|not found/)
+ })
+
+ test('rejects precompile voucher on unknown channel', async () => {
+ const { method } = createServer({ channelStateTtl: Number.MAX_SAFE_INTEGER })
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ const voucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(250n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: voucher,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/unknown channel|not found/)
+ })
+
+ describe('respond', () => {
+ function respond(action: SessionCredentialPayload['action'], input: Request) {
+ const { method } = createServer()
+ return method.respond!({
+ credential: {
+ challenge: makeChallenge(`0x${'01'.repeat(32)}` as Hex),
+ payload: { action },
+ },
+ input,
+ } as never)
+ }
+
+ test('returns 204 for close management requests', () => {
+ const result = respond('close', new Request('http://localhost', { method: 'GET' }))
+ expect(result).toBeInstanceOf(Response)
+ expect((result as Response).status).toBe(204)
+ })
+
+ test('returns 204 for top-up management requests', () => {
+ const result = respond('topUp', new Request('http://localhost', { method: 'POST' }))
+ expect(result).toBeInstanceOf(Response)
+ expect((result as Response).status).toBe(204)
+ })
+
+ test('returns 204 for open POST management requests', () => {
+ const result = respond('open', new Request('http://localhost', { method: 'POST' }))
+ expect(result).toBeInstanceOf(Response)
+ expect((result as Response).status).toBe(204)
+ })
+
+ test('returns 204 for voucher POST management requests', () => {
+ const result = respond('voucher', new Request('http://localhost', { method: 'POST' }))
+ expect(result).toBeInstanceOf(Response)
+ expect((result as Response).status).toBe(204)
+ })
+
+ test('lets open and voucher GET content requests through', () => {
+ expect(respond('open', new Request('http://localhost', { method: 'GET' }))).toBeUndefined()
+ expect(respond('voucher', new Request('http://localhost', { method: 'GET' }))).toBeUndefined()
+ })
+
+ test('lets open and voucher POST content requests with bodies through', () => {
+ expect(
+ respond(
+ 'open',
+ new Request('http://localhost', { method: 'POST', headers: { 'content-length': '1' } }),
+ ),
+ ).toBeUndefined()
+ expect(
+ respond(
+ 'voucher',
+ new Request('http://localhost', {
+ method: 'POST',
+ headers: { 'transfer-encoding': 'chunked' },
+ }),
+ ),
+ ).toBeUndefined()
+ })
+
+ test('returns 204 for voucher POST with content-length zero', () => {
+ const result = respond(
+ 'voucher',
+ new Request('http://localhost', { method: 'POST', headers: { 'content-length': '0' } }),
+ )
+ expect(result).toBeInstanceOf(Response)
+ expect((result as Response).status).toBe(204)
+ })
+ })
+
+ describe('default HTTP auto-billing', () => {
+ function createRoute(rawStore: Store.AtomicStore) {
+ return Mppx_server.create({
+ methods: [
+ tempo_server.precompile.Server.session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => createStateClient(payer),
+ }),
+ ],
+ realm: 'api.example.com',
+ secretKey: 'secret',
+ }).session({ amount: '1', decimals: 0, unitType: 'request' })
+ }
+
+ test('GET content flow charges once and rejects same-voucher replay', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 1n })
+ await persistPrecompileChannel(store, openPayload, {
+ highestVoucherAmount: 1n,
+ spent: 0n,
+ units: 0,
+ })
+ const route = createRoute(rawStore)
+ const voucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(1n),
+ chainId,
+ )
+
+ const serve = async (request: Request) => {
+ const result = await route(request)
+ if (result.status === 402) return result.challenge
+ return result.withReceipt(new Response('paid-content'))
+ }
+
+ const first = await route(new Request('https://api.example.com/resource'))
+ expect(first.status).toBe(402)
+ if (first.status !== 402) throw new Error('expected challenge')
+
+ const paid = await serve(
+ new Request('https://api.example.com/resource', {
+ headers: {
+ Authorization: Credential.serialize({
+ challenge: Challenge.fromResponse(first.challenge),
+ payload: voucher,
+ }),
+ },
+ }),
+ )
+ expect(paid.status).toBe(200)
+ expect(await paid.text()).toBe('paid-content')
+ const receipt = deserializeSessionReceipt(paid.headers.get('Payment-Receipt') as string)
+ expect(receipt.acceptedCumulative).toBe('1')
+ expect(receipt.spent).toBe('1')
+ expect(receipt.units).toBe(1)
+
+ const replayChallenge = await route(new Request('https://api.example.com/resource'))
+ expect(replayChallenge.status).toBe(402)
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
+
+ const replay = await serve(
+ new Request('https://api.example.com/resource', {
+ headers: {
+ Authorization: Credential.serialize({
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
+ payload: voucher,
+ }),
+ },
+ }),
+ )
+ expect(replay.status).toBe(402)
+ expect(replay.headers.get('Payment-Receipt')).toBeNull()
+ })
+
+ test('POST content flow charges once and rejects same-voucher replay', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 1n })
+ await persistPrecompileChannel(store, openPayload)
+ const route = createRoute(rawStore)
+ const voucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(1n),
+ chainId,
+ )
+ const makeRequest = (authorization?: string) =>
+ new Request('https://api.example.com/resource', {
+ method: 'POST',
+ body: '{}',
+ headers: {
+ 'content-length': '2',
+ 'content-type': 'application/json',
+ ...(authorization ? { Authorization: authorization } : {}),
+ },
+ })
+
+ const first = await route(makeRequest())
+ expect(first.status).toBe(402)
+ if (first.status !== 402) throw new Error('expected challenge')
+
+ const result = await route(
+ makeRequest(
+ Credential.serialize({
+ challenge: Challenge.fromResponse(first.challenge),
+ payload: voucher,
+ }),
+ ),
+ )
+ expect(result.status).toBe(200)
+ if (result.status !== 200) throw new Error('expected paid response')
+ const paid = result.withReceipt(new Response('paid-content'))
+ const receipt = deserializeSessionReceipt(paid.headers.get('Payment-Receipt') as string)
+ expect(receipt.spent).toBe('1')
+ expect(receipt.units).toBe(1)
+
+ const replayChallenge = await route(makeRequest())
+ expect(replayChallenge.status).toBe(402)
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
+ const replay = await route(
+ makeRequest(
+ Credential.serialize({
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
+ payload: voucher,
+ }),
+ ),
+ )
+ expect(replay.status).toBe(402)
+ if (replay.status !== 402) throw new Error('expected challenge')
+ expect(replay.challenge.headers.get('Payment-Receipt')).toBeNull()
+ })
+
+ test('verification errors do not include Payment-Receipt', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload)
+ const route = createRoute(rawStore)
+ const first = await route(new Request('https://api.example.com/resource'))
+ expect(first.status).toBe(402)
+ if (first.status !== 402) throw new Error('expected challenge')
+
+ const failed = await route(
+ new Request('https://api.example.com/resource', {
+ headers: {
+ Authorization: Credential.serialize({
+ challenge: Challenge.fromResponse(first.challenge),
+ payload: {
+ action: 'voucher',
+ channelId: openPayload.channelId,
+ cumulativeAmount: '100',
+ descriptor: openPayload.descriptor,
+ signature: (await createOpenPayload({ account: wrongPayer })).signature,
+ },
+ }),
+ },
+ }),
+ )
+
+ expect(failed.status).toBe(402)
+ if (failed.status !== 402) throw new Error('expected challenge')
+ expect(failed.challenge.headers.get('Payment-Receipt')).toBeNull()
+ })
+ })
+
+ describe('SSE parity', () => {
+ function createManagedSseFetch(
+ options: { amount?: string; maxDeposit?: bigint; unitType?: 'request' | 'token' } = {},
+ ) {
+ const rawStore = Store.memory()
+ let currentPayload: SessionCredentialPayload | undefined
+ let voucherPosts = 0
+ const amount = options.amount ?? '1'
+ const maxDeposit = options.maxDeposit ?? 3n
+ const unitType = options.unitType ?? 'token'
+ const route = Mppx_server.create({
+ methods: [
+ tempo_server.precompile.Server.session({
+ amount,
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payer.address,
+ sse: true,
+ store: rawStore,
+ unitType,
+ getClient: () => {
+ const payload = currentPayload
+ if (payload?.action === 'open') {
+ return createServerClient([], payer, payload.channelId, {
+ descriptor: payload.descriptor,
+ receipt: transactionReceipt([openedLog(payload, maxDeposit)]),
+ state: { settled: 0n, deposit: maxDeposit, closeRequestedAt: 0 },
+ })
+ }
+ if (payload?.action === 'close') {
+ return createServerClient([], payer, payload.channelId, {
+ receipt: transactionReceipt([
+ closedLog(payload.channelId, BigInt(payload.cumulativeAmount), 0n),
+ ]),
+ state: { settled: 0n, deposit: maxDeposit, closeRequestedAt: 0 },
+ })
+ }
+ return createStateClient(payer, {
+ settled: 0n,
+ deposit: maxDeposit,
+ closeRequestedAt: 0,
+ })
+ },
+ }),
+ ],
+ realm: 'api.example.com',
+ secretKey: 'secret',
+ }).session({ amount, decimals: 0, unitType })
+
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request = new Request(input, init)
+ currentPayload = undefined
+ if (request.headers.has('Authorization')) {
+ try {
+ currentPayload = Credential.fromRequest(request).payload
+ if (currentPayload.action === 'voucher') voucherPosts++
+ } catch {}
+ }
+
+ const result = await route(request)
+ if (result.status === 402) return result.challenge
+ if (currentPayload?.action === 'voucher') return new Response(null, { status: 200 })
+
+ if (request.headers.get('Accept')?.includes('text/event-stream')) {
+ if (unitType === 'request') {
+ const encoder = new TextEncoder()
+ return result.withReceipt(
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-1\n\n'))
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-2\n\n'))
+ controller.enqueue(encoder.encode('event: message\ndata: chunk-3\n\n'))
+ controller.close()
+ },
+ }),
+ { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } },
+ ),
+ )
+ }
+
+ return result.withReceipt(async function* (stream) {
+ await stream.charge()
+ yield 'chunk-1'
+ await stream.charge()
+ yield 'chunk-2'
+ await stream.charge()
+ yield 'chunk-3'
+ })
+ }
+
+ return result.withReceipt(new Response('ok'))
+ }
+
+ return {
+ fetch,
+ rawStore,
+ get voucherPosts() {
+ return voucherPosts
+ },
+ }
+ }
+
+ test('open -> stream -> need-voucher -> resume -> close', async () => {
+ const harness = createManagedSseFetch({ maxDeposit: 3n })
+ const manager = precompileSessionManager({
+ account: payer,
+ client: createSigningClient(),
+ decimals: 0,
+ fetch: harness.fetch,
+ maxDeposit: '3',
+ })
+
+ const chunks: string[] = []
+ const stream = await manager.sse('https://api.example.com/stream')
+ for await (const chunk of stream) chunks.push(chunk)
+
+ expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
+ expect(harness.voucherPosts).toBeGreaterThan(0)
+
+ const closeReceipt = await manager.close()
+ expect(closeReceipt?.status).toBe('success')
+ expect(closeReceipt?.spent).toBe('3')
+
+ const channelId = manager.channelId
+ expect(channelId).toBeTruthy()
+ const persisted = await ChannelStore.fromStore(harness.rawStore as never).getChannel(
+ channelId!,
+ )
+ expect(persisted?.finalized).toBe(true)
+ })
+
+ test('unitType=request auto-metered SSE responses charge once across the stream', async () => {
+ const harness = createManagedSseFetch({ maxDeposit: 1n, unitType: 'request' })
+ const manager = precompileSessionManager({
+ account: payer,
+ client: createSigningClient(),
+ decimals: 0,
+ fetch: harness.fetch,
+ maxDeposit: '1',
+ })
+
+ const chunks: string[] = []
+ const stream = await manager.sse('https://api.example.com/stream')
+ for await (const chunk of stream) chunks.push(chunk)
+
+ expect(chunks).toEqual(['chunk-1', 'chunk-2', 'chunk-3'])
+ expect(harness.voucherPosts).toBe(0)
+
+ const closeReceipt = await manager.close()
+ expect(closeReceipt?.status).toBe('success')
+ expect(closeReceipt?.spent).toBe('1')
+ })
+ })
+
+ test('does not let a racing lower voucher regress highest accepted precompile voucher', async () => {
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ const lowerVoucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(200n),
+ chainId,
+ )
+ const higherVoucher = await ClientOps.createVoucherPayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(500n),
+ chainId,
+ )
+ if (higherVoucher.action !== 'voucher') throw new Error('expected voucher payload')
+
+ const seedStore = ChannelStore.fromStore(Store.memory() as never)
+ await persistPrecompileChannel(seedStore, openPayload, {
+ highestVoucherAmount: 100n,
+ highestVoucher: {
+ channelId: openPayload.channelId,
+ cumulativeAmount: 100n,
+ signature: openPayload.signature,
+ },
+ })
+ const stale = (await seedStore.getChannel(openPayload.channelId))!
+ let stored: ChannelStore.State = {
+ ...stale,
+ highestVoucherAmount: 500n,
+ highestVoucher: {
+ channelId: openPayload.channelId,
+ cumulativeAmount: 500n,
+ signature: higherVoucher.signature,
+ },
+ }
+ const racingStore = {
+ async get(_key: string) {
+ return stale as never
+ },
+ async put(_key: string, value: unknown) {
+ stored = value as ChannelStore.State
+ },
+ async delete(_key: string) {},
+ async update(
+ _key: string,
+ fn: (current: unknown | null) => Store.Change,
+ ): Promise {
+ const change = fn(stored)
+ if (change.op === 'set') stored = change.value as ChannelStore.State
+ return change.result
+ },
+ } as Store.AtomicStore
+ const method = session({
+ account: payer,
+ amount: '1',
+ chainId,
+ channelStateTtl: Number.MAX_SAFE_INTEGER,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: racingStore,
+ unitType: 'request',
+ getClient: () => createStateClient(payer),
+ })
+
+ const receipt = await method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: lowerVoucher,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ })
+
+ expect((receipt as SessionReceipt).acceptedCumulative).toBe('500')
+ expect(stored.highestVoucherAmount).toBe(500n)
+ expect(stored.highestVoucher?.signature).toBe(higherVoucher.signature)
+ })
+
+ test('marks pending precompile close before broadcast and restores it when broadcast fails', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload, {
+ payee: payer.address,
+ })
+ let observedPending = false
+ const method = session({
+ account: payer,
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createClient({
+ account: payer,
+ chain: { id: chainId } as never,
+ transport: custom({
+ async request(args) {
+ if (args.method === 'eth_chainId') return `0x${chainId.toString(16)}`
+ if (args.method === 'eth_call')
+ return encodeFunctionResult({
+ abi: escrowAbi,
+ functionName: 'getChannelState',
+ result: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
+ })
+ if (args.method === 'eth_getTransactionCount') {
+ observedPending =
+ (await store.getChannel(openPayload.channelId))!.closeRequestedAt !== 0n
+ throw new Error('broadcast failed')
+ }
+ if (args.method === 'eth_estimateGas') return '0x5208'
+ if (args.method === 'eth_maxPriorityFeePerGas') return '0x1'
+ if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
+ throw new Error(`unexpected rpc request: ${args.method}`)
+ },
+ }),
+ }),
+ })
+ const payload = await ClientOps.createClosePayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(100n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/broadcast failed/)
+ expect(observedPending).toBe(true)
+ expect((await store.getChannel(openPayload.channelId))!.closeRequestedAt).toBe(0n)
+ })
+
+ test('precompile settle returns txHash when channel disappears before final write', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, { payee: payer.address })
+ const client = createServerClient([], payer, openPayload.channelId, {
+ receipt: transactionReceipt([settledLog(openPayload.channelId, 100n)]),
+ state: { settled: 100n, deposit: 1_000n, closeRequestedAt: 0 },
+ })
+ let deleted = false
+ const disappearingStore = {
+ async get(key: string) {
+ return rawStore.get(key)
+ },
+ async put(key: string, value: unknown) {
+ return rawStore.put(key, value)
+ },
+ async delete(key: string) {
+ return rawStore.delete(key)
+ },
+ async update(
+ key: string,
+ fn: (current: unknown | null) => Store.Change,
+ ): Promise {
+ if (!deleted) {
+ deleted = true
+ return rawStore.update(key, fn)
+ }
+ const change = fn(null)
+ return change.result
+ },
+ } as Store.AtomicStore
+
+ const { settle } = await import('./Session.js')
+ await expect(settle(disappearingStore, client, openPayload.channelId)).resolves.toBe(
+ `0x${'aa'.repeat(32)}`,
+ )
+ })
+
+ test('precompile close still returns receipt when channel disappears before final write', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 100n })
+ const closeSignature = await Voucher.signVoucher(
+ createSigningClient(),
+ payer,
+ { channelId: openPayload.channelId, cumulativeAmount: 100n },
+ tip20ChannelEscrow,
+ chainId,
+ )
+ const method = session({
+ account: payer,
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () =>
+ createServerClient([], payer, openPayload.channelId, {
+ receipt: transactionReceipt([closedLog(openPayload.channelId, 100n, 900n)]),
+ state: { settled: 0n, deposit: 1_000n, closeRequestedAt: 0 },
+ }),
+ })
+ let deleteBeforeFinalWrite = false
+ const originalUpdate = store.updateChannel.bind(store)
+ store.updateChannel = (async (channelId, fn) => {
+ if (deleteBeforeFinalWrite) return fn(null as never) as never
+ const result = await originalUpdate(channelId, fn)
+ deleteBeforeFinalWrite = true
+ return result
+ }) as typeof store.updateChannel
+
+ const receipt = (await method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: {
+ action: 'close',
+ channelId: openPayload.channelId,
+ cumulativeAmount: '100',
+ descriptor: openPayload.descriptor,
+ signature: closeSignature,
+ },
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ })) as SessionReceipt
+
+ expect(receipt.txHash).toBe(`0x${'aa'.repeat(32)}`)
+ expect(receipt.spent).toBe('100')
+ })
+
+ test('rejects close when precompile channel is finalized on-chain', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, { payee: payer.address, spent: 100n })
+ const closeSignature = await Voucher.signVoucher(
+ createSigningClient(),
+ payer,
+ { channelId: openPayload.channelId, cumulativeAmount: 100n },
+ tip20ChannelEscrow,
+ chainId,
+ )
+ const method = session({
+ account: payer,
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => createStateClient(payer, { settled: 0n, deposit: 0n, closeRequestedAt: 0 }),
+ })
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload: {
+ action: 'close',
+ channelId: openPayload.channelId,
+ cumulativeAmount: '100',
+ descriptor: openPayload.descriptor,
+ signature: closeSignature,
+ },
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/channel deposit is zero/)
+ })
+
+ test('pending precompile close blocks concurrent charges', async () => {
+ const { store } = createServer()
+ const openPayload = await createOpenPayload({ initialAmount: 100n })
+ await persistPrecompileChannel(store, openPayload, {
+ closeRequestedAt: 1n,
+ highestVoucherAmount: 500n,
+ spent: 100n,
+ })
+
+ await expect(charge(store, openPayload.channelId, 1n)).rejects.toThrow(/pending close request/)
+ })
+
+ test('rejects server-driven close when no account is available', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload)
+ const method = session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => createStateClient(null),
+ })
+ const payload = await ClientOps.createClosePayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(100n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/no account available/)
+ })
+
+ test('accepts server-driven close account override matching the channel payee', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload, {
+ payee: wrongPayer.address,
+ })
+ const method = session({
+ account: wrongPayer,
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => createStateClient(payer),
+ })
+ const payload = await ClientOps.createClosePayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(100n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/eth_sendRawTransaction/)
+ })
+
+ test('uses request-specified fee payer account for server-driven precompile close', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload, {
+ payee: wrongPayer.address,
+ })
+ const method = session({
+ account: wrongPayer,
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ feeToken: token,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => createStateClient(payer),
+ })
+ const payload = await ClientOps.createClosePayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(100n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: {
+ ...makeRequest(openPayload.channelId),
+ feePayer: payer,
+ methodDetails: {
+ ...makeRequest(openPayload.channelId).methodDetails,
+ feePayer: true,
+ },
+ } as never,
+ }),
+ ).rejects.toThrow(/eth_sendRawTransaction/)
+ })
+
+ test('accepts server-driven close sender matching a nonzero precompile operator', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload({
+ operator: wrongPayer.address,
+ })
+ await persistPrecompileChannel(store, openPayload)
+ const method = session({
+ account: wrongPayer,
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => createStateClient(payer),
+ })
+ const payload = await ClientOps.createClosePayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(100n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/eth_sendRawTransaction/)
+ })
+
+ test('rejects server-driven close when sender is not the channel payee or operator', async () => {
+ const rawStore = Store.memory()
+ const store = ChannelStore.fromStore(rawStore as never)
+ const openPayload = await createOpenPayload()
+ await persistPrecompileChannel(store, openPayload)
+ const method = session({
+ amount: '1',
+ chainId,
+ currency: token,
+ decimals: 0,
+ recipient: payee,
+ store: rawStore,
+ unitType: 'request',
+ getClient: () => createStateClient(wrongPayer),
+ })
+ const payload = await ClientOps.createClosePayload(
+ createSigningClient(),
+ payer,
+ openPayload.descriptor,
+ Types.uint96(100n),
+ chainId,
+ )
+
+ await expect(
+ method.verify({
+ credential: {
+ challenge: makeChallenge(openPayload.channelId),
+ payload,
+ },
+ request: makeRequest(openPayload.channelId) as never,
+ }),
+ ).rejects.toThrow(/tx sender .* is not the channel payee/)
+ })
+})
diff --git a/src/tempo/precompile/server/Session.ts b/src/tempo/precompile/server/Session.ts
new file mode 100644
index 00000000..015884b6
--- /dev/null
+++ b/src/tempo/precompile/server/Session.ts
@@ -0,0 +1,1014 @@
+/**
+ * Server-side TIP-1034 precompile session payment method for request/response flows.
+ *
+ * Handles the full TIP20EscrowChannel lifecycle (open, voucher, top-up, close)
+ * and one-shot settlement. Each incoming request carries a session credential
+ * with a cumulative voucher that the server validates and records.
+ */
+import {
+ type Address,
+ type Hex,
+ isAddressEqual,
+ parseUnits,
+ zeroAddress,
+ type Account as viem_Account,
+} from 'viem'
+import { tempo as tempo_chain } from 'viem/chains'
+
+import {
+ AmountExceedsDepositError,
+ BadRequestError,
+ ChannelClosedError,
+ ChannelNotFoundError,
+ DeltaTooSmallError,
+ InsufficientBalanceError,
+ InvalidSignatureError,
+ VerificationFailedError,
+} from '../../../Errors.js'
+import type { Challenge, Credential } from '../../../index.js'
+import type { LooseOmit, NoExtraKeys } from '../../../internal/types.js'
+import * as Method from '../../../Method.js'
+import * as Store from '../../../Store.js'
+import * as Client from '../../../viem/Client.js'
+import type * as z from '../../../zod.js'
+import * as defaults from '../../internal/defaults.js'
+import * as FeePayer from '../../internal/fee-payer.js'
+import type * as types from '../../internal/types.js'
+import * as Methods from '../../Methods.js'
+import {
+ captureRequestBodyProbe,
+ isSessionContentRequest,
+} from '../../server/internal/request-body.js'
+import * as Transport from '../../server/internal/transport.js'
+import type { SessionMethodDetails } from '../../server/Session.js'
+import * as ChannelStore from '../../session/ChannelStore.js'
+import { createSessionReceipt } from '../../session/Receipt.js'
+import type { SessionReceipt } from '../../session/Types.js'
+import * as Chain from '../Chain.js'
+import * as Channel from '../Channel.js'
+import { tip20ChannelEscrow } from '../Constants.js'
+import { type SessionCredentialPayload, type SignedVoucher, uint96 } from '../Types.js'
+import * as Voucher from '../Voucher.js'
+
+/** Creates a server-side TIP20EscrowChannel precompile session payment method. */
+export function session(
+ p?: NoExtraKeys,
+) {
+ const parameters = p as parameters
+ const {
+ amount,
+ channelStateTtl = 5_000,
+ currency = defaults.resolveCurrency(parameters),
+ decimals = defaults.decimals,
+ store: rawStore = Store.memory(),
+ suggestedDeposit,
+ unitType,
+ } = parameters
+
+ const store = ChannelStore.fromStore(rawStore as never)
+ const lastOnChainVerified = new Map()
+ const recipient = parameters.recipient as Address
+ const getClient = Client.getResolver({
+ chain: tempo_chain,
+ getClient: parameters.getClient,
+ rpcUrl: defaults.rpcUrl,
+ })
+
+ type Transport = parameters['sse'] extends false | undefined ? undefined : Transport.Sse
+ const transport = parameters.sse
+ ? Transport.sse({
+ store,
+ ...(typeof parameters.sse === 'object' ? parameters.sse : undefined),
+ })
+ : undefined
+
+ type Defaults = session.DeriveDefaults
+ return Method.toServer(Methods.session, {
+ defaults: {
+ amount,
+ currency,
+ decimals,
+ recipient,
+ suggestedDeposit,
+ unitType,
+ } as unknown as Defaults,
+
+ // TODO: dedupe `{charge,session}.request`
+ transport: transport as never,
+
+ async request({ credential, request }) {
+ // Extract chainId from request or default.
+ const chainId = await (async () => {
+ if (request.chainId) return request.chainId
+ if (parameters.chainId) return parameters.chainId
+ return (await getClient({})).chain?.id
+ })()
+ if (!chainId) throw new Error('No chainId configured for tempo.precompile.session().')
+
+ // Validate chainId.
+ const client = await getClient({ chainId })
+ if (client.chain?.id !== chainId)
+ throw new Error(`Client not configured with chainId ${chainId}.`)
+ // Extract feePayer.
+ const resolvedFeePayer = (() => {
+ if (request.feePayer === false) return credential ? false : undefined
+ const account =
+ typeof request.feePayer === 'object' ? request.feePayer : parameters.feePayer
+ if (credential) return account ?? undefined
+ if (account) return true
+ return undefined
+ })()
+ return {
+ ...request,
+ chainId,
+ escrowContract: request.escrowContract ?? parameters.escrowContract ?? tip20ChannelEscrow,
+ feePayer: resolvedFeePayer,
+ }
+ },
+
+ async verify({ credential, envelope, request }) {
+ const { challenge, payload: rawPayload } = credential
+ const payload = rawPayload as SessionCredentialPayload
+ const resolvedRequest = (() => {
+ const parsed = Methods.session.schema.request.safeParse(request)
+ if (parsed.success) return parsed.data
+ // verifyCredential() passes the HMAC-bound challenge request, which is
+ // already in canonical output form and should not be transformed again.
+ return request as unknown as z.output
+ })()
+ const methodDetails = resolvedRequest.methodDetails as SessionMethodDetails | undefined
+ if (!methodDetails) throw new VerificationFailedError({ reason: 'missing methodDetails' })
+ const chainId = methodDetails.chainId
+ const escrow = methodDetails.escrowContract
+ const client = await getClient({ chainId })
+ const requestAllowsFeePayer =
+ request.feePayer !== false &&
+ (request.feePayer === undefined ||
+ request.feePayer === true ||
+ typeof request.feePayer === 'object')
+ const resolvedFeePayer =
+ methodDetails.feePayer === true && requestAllowsFeePayer
+ ? typeof request.feePayer === 'object'
+ ? request.feePayer
+ : parameters.feePayer
+ : undefined
+ const minVoucherDelta = methodDetails.minVoucherDelta
+ ? BigInt(methodDetails.minVoucherDelta)
+ : parseUnits(parameters.minVoucherDelta ?? '0', decimals)
+
+ let sessionReceipt: SessionReceipt
+
+ switch (payload.action) {
+ case 'open': {
+ sessionReceipt = await handleOpen({
+ store,
+ client,
+ challenge,
+ payload,
+ chainId,
+ escrow,
+ feePayer: resolvedFeePayer,
+ feePayerPolicy: parameters.feePayerPolicy,
+ })
+ lastOnChainVerified.set(sessionReceipt.channelId as Hex, Date.now())
+ break
+ }
+ case 'topUp': {
+ sessionReceipt = await handleTopUp({
+ store,
+ client,
+ challenge,
+ payload,
+ chainId,
+ escrow,
+ feePayer: resolvedFeePayer,
+ feePayerPolicy: parameters.feePayerPolicy,
+ })
+ lastOnChainVerified.set(sessionReceipt.channelId as Hex, Date.now())
+ break
+ }
+ case 'voucher': {
+ sessionReceipt = await handleVoucher({
+ store,
+ client,
+ challenge,
+ payload,
+ chainId,
+ escrow,
+ channelStateTtl,
+ lastOnChainVerified,
+ minVoucherDelta,
+ })
+ break
+ }
+ case 'close': {
+ sessionReceipt = await handleClose({
+ store,
+ client,
+ challenge,
+ payload,
+ chainId,
+ escrow,
+ account: parameters.account,
+ feePayer: resolvedFeePayer,
+ feePayerPolicy: parameters.feePayerPolicy,
+ feeToken: parameters.feeToken,
+ })
+ break
+ }
+ default:
+ throw new VerificationFailedError({ reason: 'unsupported precompile session action' })
+ }
+
+ // In the default HTTP request/response mode, each successful content
+ // request consumes one unit immediately after the credential is accepted.
+ // This keeps equal-voucher replays bounded by the voucher's remaining
+ // balance instead of serving repeated responses for free.
+ if (
+ envelope &&
+ isSessionContentRequest(envelope.capturedRequest) &&
+ (payload.action === 'open' || payload.action === 'voucher')
+ ) {
+ const charged = await charge(
+ store,
+ sessionReceipt.channelId as Hex,
+ BigInt(resolvedRequest.amount ?? challenge.request.amount),
+ )
+ sessionReceipt = {
+ ...sessionReceipt,
+ spent: charged.spent.toString(),
+ units: charged.units,
+ }
+ if (parameters.sse) sessionReceipt = Transport.markPrepaidSessionTick(sessionReceipt)
+ }
+
+ return sessionReceipt
+ },
+
+ // This hook acts as a gate: when it returns a Response, `withReceipt()`
+ // in Mppx.ts short-circuits and returns that response directly without
+ // invoking the user's route handler. When it returns undefined, the
+ // user's handler runs normally and serves content.
+ //
+ // close and topUp are always gated (204) — they are pure management.
+ //
+ // open and voucher share the same captured-request classifier used
+ // during verification. Non-billable requests are treated as management
+ // updates; billable requests fall through to the application handler.
+ respond({ credential, envelope, input }) {
+ const { payload } = credential as Credential.Credential
+
+ if (payload.action === 'close') return new Response(null, { status: 204 })
+ if (payload.action === 'topUp') return new Response(null, { status: 204 })
+
+ const request = envelope?.capturedRequest ?? captureRequestBodyProbe(input)
+ if (isSessionContentRequest(request)) return undefined
+ return new Response(null, { status: 204 })
+ },
+ })
+}
+
+export namespace session {
+ export type Defaults = LooseOmit, 'feePayer'>
+
+ export type FeePayerPolicy = Partial
+
+ export type Parameters = {
+ /** TTL in milliseconds for cached on-chain channel state. After this duration, the server re-queries on-chain state during voucher handling to detect forced close requests. @default 5_000 */
+ channelStateTtl?: number | undefined
+ /** Override the fee-sponsor policy used for sponsored open/topUp transactions and server-driven close transactions. */
+ feePayerPolicy?: FeePayerPolicy | undefined
+ /** Minimum voucher delta to accept (numeric string, default: "0"). */
+ minVoucherDelta?: string | undefined
+ /**
+ * Atomic store backend for channel state.
+ *
+ * Session mutations must be linearizable across instances so spent,
+ * highest-voucher, top-up, and close/finalization updates cannot race.
+ * Use `Store.memory()` for tests or local single-process usage.
+ */
+ store?: Store.AtomicStore | undefined
+ /** Enable SSE streaming. Pass `true` for defaults or an options object to configure SSE. */
+ sse?: boolean | Transport.sse.Options | undefined
+ /** Tempo chain ID used for TIP-1034 channel escrow challenges. Defaults to the resolved client chain ID. Pass the Tempo testnet chain ID here instead of using legacy session's `testnet` boolean. */
+ chainId?: number | undefined
+
+ /** Account used for server-driven close and settle transactions. Defaults to the client account. */
+ account?: viem_Account | undefined
+ /** Optional fee payer used to sponsor server-driven close transactions. */
+ feePayer?: viem_Account | undefined
+ /** Optional fee token used for server-driven close transactions. */
+ feeToken?: Address | undefined
+ } & Client.getResolver.Parameters &
+ Defaults
+
+ export type DeriveDefaults = types.DeriveDefaults<
+ parameters,
+ Defaults
+ >
+}
+
+function assertSettlementSender(parameters: {
+ operation: 'close' | 'settle'
+ channelId: Hex
+ operator: Address
+ payee: Address
+ sender: Address | undefined
+}) {
+ const { operation, channelId, operator, payee, sender } = parameters
+ if (!sender)
+ throw new Error(
+ `Cannot ${operation} precompile channel ${channelId}: no account available. Pass an account override, or provide a getClient() that returns an account-bearing client.`,
+ )
+ if (isAddressEqual(sender, payee)) return
+ if (!isAddressEqual(operator, zeroAddress) && isAddressEqual(sender, operator)) return
+ throw new BadRequestError({
+ reason:
+ `Cannot ${operation} precompile channel ${channelId}: tx sender ${sender} is not the channel payee ${payee}` +
+ (isAddressEqual(operator, zeroAddress) ? '.' : ` or operator ${operator}.`) +
+ ' If using an access key, pass a Tempo access-key account whose address is the payee/operator wallet, not the raw delegated key address.',
+ })
+}
+
+/** Settles the highest accepted voucher for a precompile-backed session channel. */
+export async function settle(
+ store_: Store.Store | ChannelStore.ChannelStore,
+ client: Chain.TransactionClient,
+ channelId_: Hex,
+ options?: {
+ account?: viem_Account | undefined
+ candidateFeeTokens?: readonly Address[] | undefined
+ escrowContract?: Address | undefined
+ feePayer?: viem_Account | undefined
+ feePayerPolicy?: session.FeePayerPolicy | undefined
+ feeToken?: Address | undefined
+ },
+): Promise {
+ const store = 'getChannel' in store_ ? store_ : ChannelStore.fromStore(store_ as never)
+ const channelId = ChannelStore.normalizeChannelId(channelId_)
+ const channel = await store.getChannel(channelId)
+ if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' })
+ if (!ChannelStore.isPrecompileState(channel))
+ throw new VerificationFailedError({ reason: 'channel is not precompile-backed' })
+ if (!channel.highestVoucher) throw new VerificationFailedError({ reason: 'no voucher to settle' })
+ const escrow = options?.escrowContract ?? channel.escrowContract
+ const account = options?.account ?? getClientAccount(client)
+ assertSettlementSender({
+ operation: 'settle',
+ channelId,
+ operator: channel.operator,
+ payee: channel.payee,
+ sender: account?.address,
+ })
+ const amount = uint96(channel.highestVoucher.cumulativeAmount)
+ const txHash = await Chain.settleOnChain(
+ client,
+ channel.descriptor,
+ amount,
+ channel.highestVoucher.signature,
+ escrow,
+ account
+ ? {
+ account,
+ ...(options?.feePayer ? { feePayer: options.feePayer } : {}),
+ ...(options?.feePayerPolicy ? { feePayerPolicy: options.feePayerPolicy } : {}),
+ ...(options?.feeToken ? { feeToken: options.feeToken } : {}),
+ candidateFeeTokens: options?.candidateFeeTokens ?? [channel.token],
+ }
+ : undefined,
+ )
+ const receipt = await Chain.waitForSuccessfulReceipt(client, txHash)
+ const settled = Chain.getChannelEvent(receipt, 'Settled', channelId)
+ const newSettled = uint96(settled.args.newSettled as bigint)
+ if (newSettled < amount)
+ throw new VerificationFailedError({ reason: 'Settled event is below voucher amount' })
+ const state = await Chain.getChannelState(client, channelId, escrow)
+ if (state.settled !== newSettled)
+ throw new VerificationFailedError({
+ reason: 'on-chain channel state does not match settle receipt',
+ })
+ await store.updateChannel(channelId, (current) =>
+ current
+ ? {
+ ...current,
+ settledOnChain: newSettled > current.settledOnChain ? newSettled : current.settledOnChain,
+ }
+ : current,
+ )
+ return txHash
+}
+
+/**
+ * Charge against a precompile-backed channel's balance.
+ *
+ * Exported so consumers can deduct from a channel outside the `session()`
+ * handler.
+ *
+ * Delegates to the shared `deductFromChannel` atomic helper and translates
+ * failure modes into typed errors (`InsufficientBalanceError`, `ChannelClosedError`).
+ */
+export async function charge(
+ store: ChannelStore.ChannelStore,
+ channelId: Hex,
+ amount: bigint,
+): Promise {
+ let result: Awaited>
+ try {
+ result = await ChannelStore.deductFromChannel(store, channelId, amount)
+ } catch {
+ throw new ChannelClosedError({ reason: 'channel not found' })
+ }
+ if (!result.ok) {
+ if (result.channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' })
+ if (result.channel.closeRequestedAt !== 0n)
+ throw new ChannelClosedError({ reason: 'channel has a pending close request' })
+ const available = result.channel.highestVoucherAmount - result.channel.spent
+ throw new InsufficientBalanceError({
+ reason: `requested ${amount}, available ${available}`,
+ })
+ }
+ return result.channel
+}
+
+function authorizedSigner(descriptor: Channel.ChannelDescriptor): Address {
+ return isAddressEqual(descriptor.authorizedSigner, zeroAddress)
+ ? descriptor.payer
+ : descriptor.authorizedSigner
+}
+
+function getClientAccount(client: { account?: viem_Account | undefined }) {
+ return client.account
+}
+
+function assertDescriptor(payload: {
+ descriptor?: Channel.ChannelDescriptor | undefined
+}): asserts payload is { descriptor: Channel.ChannelDescriptor } {
+ if (!payload.descriptor)
+ throw new VerificationFailedError({
+ reason: 'descriptor required for precompile session action',
+ })
+}
+
+function assertSameDescriptor(a: Channel.ChannelDescriptor, b: Channel.ChannelDescriptor) {
+ if (
+ !isAddressEqual(a.payer, b.payer) ||
+ !isAddressEqual(a.payee, b.payee) ||
+ !isAddressEqual(a.operator, b.operator) ||
+ !isAddressEqual(a.token, b.token) ||
+ !isAddressEqual(a.authorizedSigner, b.authorizedSigner) ||
+ a.salt.toLowerCase() !== b.salt.toLowerCase() ||
+ a.expiringNonceHash.toLowerCase() !== b.expiringNonceHash.toLowerCase()
+ )
+ throw new VerificationFailedError({
+ reason: 'credential descriptor does not match stored channel',
+ })
+}
+
+/**
+ * Validate a TIP20EscrowChannel descriptor against the credential channel ID and expected payment destination.
+ */
+function validateChannelDescriptor(
+ descriptor: Channel.ChannelDescriptor,
+ channelId: Hex,
+ chainId: number,
+ escrow: Address,
+ recipient: Address,
+ currency: Address,
+): void {
+ const computed = Channel.computeId({ ...descriptor, chainId, escrow })
+ if (computed.toLowerCase() !== channelId.toLowerCase()) {
+ throw new VerificationFailedError({ reason: 'channel descriptor does not match channelId' })
+ }
+ if (!isAddressEqual(descriptor.payee, recipient)) {
+ throw new VerificationFailedError({
+ reason: 'channel descriptor payee does not match server destination',
+ })
+ }
+ if (!isAddressEqual(descriptor.token, currency)) {
+ throw new VerificationFailedError({
+ reason: 'channel descriptor token does not match server token',
+ })
+ }
+}
+
+/**
+ * Validate on-chain channel state.
+ */
+function validateChannelState(state: Chain.ChannelState, amount?: bigint): void {
+ if (state.deposit === 0n) {
+ throw new ChannelNotFoundError({ reason: 'channel not funded on-chain' })
+ }
+ if (state.closeRequestedAt !== 0) {
+ throw new ChannelClosedError({ reason: 'channel has a pending close request' })
+ }
+ if (amount !== undefined && state.deposit - state.settled < amount) {
+ throw new InsufficientBalanceError({
+ reason: 'channel available balance insufficient for requested amount',
+ })
+ }
+}
+
+/**
+ * Shared logic for verifying an incremental voucher and updating channel state.
+ * Used by handleVoucher after descriptor and cache resolution.
+ */
+async function verifyAndAcceptVoucher(parameters: {
+ store: ChannelStore.ChannelStore
+ minVoucherDelta: bigint
+ challenge: Challenge.Challenge
+ channel: ChannelStore.State
+ voucher: SignedVoucher
+ channelState: Chain.ChannelState
+ methodDetails: SessionMethodDetails
+}): Promise {
+ const { store, minVoucherDelta, challenge, channel, voucher, channelState, methodDetails } =
+ parameters
+
+ validateChannelState(channelState)
+ if (voucher.cumulativeAmount <= channelState.settled)
+ throw new VerificationFailedError({
+ reason: 'voucher cumulativeAmount is below on-chain settled amount',
+ })
+ if (voucher.cumulativeAmount > channelState.deposit)
+ throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' })
+ if (voucher.cumulativeAmount < channel.highestVoucherAmount)
+ throw new VerificationFailedError({
+ reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
+ })
+ const valid = await Voucher.verifyVoucher(
+ methodDetails.escrowContract,
+ methodDetails.chainId,
+ voucher,
+ channel.authorizedSigner,
+ )
+ if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
+
+ // Idempotent replay: equal cumulative voucher is accepted without
+ // advancing channel state or charging additional value.
+ if (voucher.cumulativeAmount === channel.highestVoucherAmount)
+ return createSessionReceipt({
+ challengeId: challenge.id,
+ channelId: voucher.channelId,
+ acceptedCumulative: channel.highestVoucherAmount,
+ spent: channel.spent,
+ units: channel.units,
+ })
+ const delta = voucher.cumulativeAmount - channel.highestVoucherAmount
+ if (delta < minVoucherDelta)
+ throw new DeltaTooSmallError({
+ reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`,
+ })
+ const updated = await store.updateChannel(voucher.channelId, (current) =>
+ (() => {
+ if (!current) throw new ChannelNotFoundError({ reason: 'channel not found' })
+ if (current.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' })
+ if (current.closeRequestedAt !== 0n)
+ throw new ChannelClosedError({ reason: 'channel has a pending close request' })
+
+ const nextDeposit =
+ channelState.deposit > current.deposit ? channelState.deposit : current.deposit
+ const nextSettled =
+ channelState.settled > current.settledOnChain
+ ? channelState.settled
+ : current.settledOnChain
+ if (voucher.cumulativeAmount > current.highestVoucherAmount) {
+ return {
+ ...current,
+ deposit: nextDeposit,
+ settledOnChain: nextSettled,
+ highestVoucherAmount: voucher.cumulativeAmount,
+ highestVoucher: voucher,
+ }
+ }
+ return { ...current, deposit: nextDeposit, settledOnChain: nextSettled }
+ })(),
+ )
+ if (!updated) throw new ChannelNotFoundError({ reason: 'channel not found' })
+ return createSessionReceipt({
+ challengeId: challenge.id,
+ channelId: voucher.channelId,
+ acceptedCumulative: updated.highestVoucherAmount,
+ spent: updated.spent,
+ units: updated.units,
+ })
+}
+
+/**
+ * Handle 'open' action - verify voucher, create channel, and broadcast.
+ */
+async function handleOpen(parameters: {
+ store: ChannelStore.ChannelStore
+ client: Chain.TransactionClient
+ challenge: Challenge.Challenge
+ payload: SessionCredentialPayload & { action: 'open' }
+ chainId: number
+ escrow: Address
+ feePayer?: viem_Account | undefined
+ feePayerPolicy?: session.FeePayerPolicy | undefined
+}): Promise {
+ const { store, client, challenge, payload, chainId, escrow } = parameters
+ const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount))
+ assertDescriptor(payload)
+ if (
+ payload.authorizedSigner !== undefined &&
+ !isAddressEqual(payload.authorizedSigner, payload.descriptor.authorizedSigner)
+ )
+ throw new VerificationFailedError({
+ reason: 'credential authorizedSigner does not match descriptor',
+ })
+ const channelId = ChannelStore.normalizeChannelId(payload.channelId)
+ validateChannelDescriptor(
+ payload.descriptor,
+ channelId,
+ chainId,
+ escrow,
+ challenge.request.recipient as Address,
+ challenge.request.currency as Address,
+ )
+
+ const result = await Chain.broadcastOpenTransaction({
+ challengeExpires: challenge.expires,
+ chainId,
+ client,
+ escrowContract: escrow,
+ expectedAuthorizedSigner: payload.descriptor.authorizedSigner,
+ expectedChannelId: channelId,
+ expectedCurrency: challenge.request.currency as Address,
+ expectedOperator: payload.descriptor.operator,
+ expectedPayee: challenge.request.recipient as Address,
+ expectedExpiringNonceHash: payload.descriptor.expiringNonceHash,
+ expectedPayer: payload.descriptor.payer,
+ feePayer: parameters.feePayer,
+ feePayerPolicy: parameters.feePayerPolicy,
+ serializedTransaction: payload.transaction,
+ async beforeBroadcast(prepared) {
+ assertSameDescriptor(prepared.descriptor, payload.descriptor)
+ if (cumulativeAmount > prepared.openDeposit)
+ throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds open deposit' })
+ const valid = await Voucher.verifyVoucher(
+ escrow,
+ chainId,
+ { channelId, cumulativeAmount: cumulativeAmount, signature: payload.signature },
+ authorizedSigner(prepared.descriptor),
+ )
+ if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
+ },
+ })
+ const { descriptor, state } = result
+ assertSameDescriptor(descriptor, payload.descriptor)
+ const amount = challenge.request.amount ? BigInt(challenge.request.amount as string) : undefined
+ validateChannelState(state, amount)
+
+ const updated = await store.updateChannel(channelId, (current) => ({
+ ...(current ?? {}),
+ backend: 'precompile',
+ channelId: channelId,
+ chainId,
+ escrowContract: escrow,
+ closeRequestedAt:
+ current && current.closeRequestedAt > BigInt(state.closeRequestedAt)
+ ? current.closeRequestedAt
+ : BigInt(state.closeRequestedAt),
+ payer: descriptor.payer,
+ payee: descriptor.payee,
+ token: descriptor.token,
+ authorizedSigner: authorizedSigner(descriptor),
+ deposit: state.deposit,
+ settledOnChain:
+ current && current.settledOnChain > state.settled ? current.settledOnChain : state.settled,
+ highestVoucherAmount:
+ current?.highestVoucherAmount && current.highestVoucherAmount > cumulativeAmount
+ ? current.highestVoucherAmount
+ : cumulativeAmount,
+ highestVoucher:
+ current?.highestVoucherAmount && current.highestVoucherAmount > cumulativeAmount
+ ? current.highestVoucher
+ : {
+ channelId: channelId,
+ cumulativeAmount: cumulativeAmount,
+ signature: payload.signature,
+ },
+ spent: current && current.spent > state.settled ? current.spent : state.settled,
+ units: current?.units ?? 0,
+ finalized: current?.finalized ?? false,
+ createdAt: current?.createdAt ?? new Date().toISOString(),
+ descriptor,
+ operator: descriptor.operator,
+ salt: descriptor.salt,
+ expiringNonceHash: result.expiringNonceHash,
+ }))
+ if (!updated) throw new VerificationFailedError({ reason: 'failed to create channel' })
+ return createSessionReceipt({
+ challengeId: challenge.id,
+ channelId,
+ acceptedCumulative: updated.highestVoucherAmount,
+ spent: updated.spent,
+ units: updated.units,
+ txHash: result.txHash,
+ })
+}
+
+/**
+ * Handle 'topUp' action - broadcast topUp transaction and update channel deposit.
+ *
+ * Per spec Section 8.3.2, topUp payloads contain only the transaction and
+ * additionalDeposit — no voucher. The client must send a separate 'voucher'
+ * action to authorize spending the new funds.
+ */
+async function handleTopUp(parameters: {
+ store: ChannelStore.ChannelStore
+ client: Chain.TransactionClient
+ challenge: Challenge.Challenge
+ payload: SessionCredentialPayload & { action: 'topUp' }
+ chainId: number
+ escrow: Address
+ feePayer?: viem_Account | undefined
+ feePayerPolicy?: session.FeePayerPolicy | undefined
+}): Promise {
+ const { store, client, challenge, payload, chainId, escrow } = parameters
+ const additionalDeposit = uint96(BigInt(payload.additionalDeposit))
+ assertDescriptor(payload)
+ const channelId = ChannelStore.normalizeChannelId(payload.channelId)
+ const channel = await store.getChannel(channelId)
+ if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' })
+ if (!ChannelStore.isPrecompileState(channel))
+ throw new VerificationFailedError({ reason: 'channel is not precompile-backed' })
+ assertSameDescriptor(payload.descriptor, channel.descriptor)
+ validateChannelDescriptor(
+ payload.descriptor,
+ channelId,
+ chainId,
+ escrow,
+ channel.payee,
+ channel.token,
+ )
+ const result = await Chain.broadcastTopUpTransaction({
+ additionalDeposit,
+ challengeExpires: challenge.expires,
+ chainId,
+ client,
+ descriptor: channel.descriptor,
+ escrowContract: escrow,
+ expectedChannelId: channelId,
+ expectedCurrency: channel.token,
+ feePayer: parameters.feePayer,
+ feePayerPolicy: parameters.feePayerPolicy,
+ serializedTransaction: payload.transaction,
+ })
+ const { newDeposit, state } = result
+ validateChannelState(state)
+
+ const updated = await store.updateChannel(channelId, (current) =>
+ current
+ ? {
+ ...current,
+ deposit: newDeposit > current.deposit ? newDeposit : current.deposit,
+ settledOnChain:
+ state.settled > current.settledOnChain ? state.settled : current.settledOnChain,
+ closeRequestedAt:
+ BigInt(state.closeRequestedAt) > current.closeRequestedAt
+ ? BigInt(state.closeRequestedAt)
+ : current.closeRequestedAt,
+ }
+ : current,
+ )
+ return createSessionReceipt({
+ challengeId: challenge.id,
+ channelId,
+ acceptedCumulative: updated?.highestVoucherAmount ?? channel.highestVoucherAmount,
+ spent: updated?.spent ?? channel.spent,
+ units: updated?.units ?? channel.units,
+ txHash: result.txHash,
+ })
+}
+
+/**
+ * Handle 'voucher' action - verify and accept a new voucher.
+ */
+async function handleVoucher(parameters: {
+ store: ChannelStore.ChannelStore
+ client: Chain.TransactionClient
+ challenge: Challenge.Challenge
+ payload: SessionCredentialPayload & { action: 'voucher' }
+ chainId: number
+ escrow: Address
+ minVoucherDelta: bigint
+ channelStateTtl: number
+ lastOnChainVerified: Map
+}): Promise {
+ const {
+ store,
+ client,
+ challenge,
+ payload,
+ chainId,
+ escrow,
+ minVoucherDelta,
+ channelStateTtl,
+ lastOnChainVerified,
+ } = parameters
+ const channelId = ChannelStore.normalizeChannelId(payload.channelId)
+ const voucher = Voucher.parseVoucherFromPayload(
+ channelId,
+ payload.cumulativeAmount,
+ payload.signature,
+ )
+ assertDescriptor(payload)
+ const channel = await store.getChannel(channelId)
+ if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' })
+ if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' })
+ if (!ChannelStore.isPrecompileState(channel))
+ throw new VerificationFailedError({ reason: 'channel is not precompile-backed' })
+ assertSameDescriptor(payload.descriptor, channel.descriptor)
+ validateChannelDescriptor(
+ payload.descriptor,
+ channelId,
+ chainId,
+ escrow,
+ channel.payee,
+ channel.token,
+ )
+ const isStale = Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl
+ const state = isStale ? await Chain.getChannelState(client, channelId, escrow) : undefined
+ if (state) lastOnChainVerified.set(channelId, Date.now())
+ const channelState = {
+ deposit: state?.deposit ?? uint96(channel.deposit),
+ settled: state?.settled ?? uint96(channel.settledOnChain),
+ closeRequestedAt: state?.closeRequestedAt ?? Number(channel.closeRequestedAt),
+ }
+ if (channelState.closeRequestedAt !== 0) {
+ // Persist closeRequestedAt so the cached path detects force-close
+ // between re-queries.
+ await store.updateChannel(channelId, (current) =>
+ current
+ ? {
+ ...current,
+ closeRequestedAt:
+ BigInt(channelState.closeRequestedAt) > current.closeRequestedAt
+ ? BigInt(channelState.closeRequestedAt)
+ : current.closeRequestedAt,
+ }
+ : current,
+ )
+ }
+ return verifyAndAcceptVoucher({
+ store,
+ minVoucherDelta,
+ challenge,
+ channel,
+ voucher,
+ channelState,
+ methodDetails: { chainId, escrowContract: escrow },
+ })
+}
+
+/**
+ * Handle 'close' action - verify final voucher and close channel.
+ */
+async function handleClose(parameters: {
+ store: ChannelStore.ChannelStore
+ client: Chain.TransactionClient
+ challenge: Challenge.Challenge
+ payload: SessionCredentialPayload & { action: 'close' }
+ chainId: number
+ escrow: Address
+ account?: viem_Account | undefined
+ feePayer?: viem_Account | undefined
+ feePayerPolicy?: session.FeePayerPolicy | undefined
+ feeToken?: Address | undefined
+}): Promise {
+ const { store, client, challenge, payload, chainId, escrow } = parameters
+ const cumulativeAmount = uint96(BigInt(payload.cumulativeAmount))
+ const channelId = ChannelStore.normalizeChannelId(payload.channelId)
+ assertDescriptor(payload)
+ const channel = await store.getChannel(channelId)
+ if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' })
+ if (!ChannelStore.isPrecompileState(channel))
+ throw new VerificationFailedError({ reason: 'channel is not precompile-backed' })
+ if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is already finalized' })
+ assertSameDescriptor(payload.descriptor, channel.descriptor)
+ const state = await Chain.getChannelState(client, channelId, escrow)
+ if (state.closeRequestedAt !== 0)
+ throw new ChannelClosedError({ reason: 'channel has a pending close request' })
+ if (state.deposit === 0n && (cumulativeAmount !== 0n || channel.spent !== 0n))
+ throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' })
+ if (cumulativeAmount < channel.spent)
+ throw new VerificationFailedError({
+ reason: `close voucher amount must be >= ${channel.spent} (spent)`,
+ })
+ if (cumulativeAmount < state.settled)
+ throw new VerificationFailedError({
+ reason: `close voucher amount must be >= ${state.settled} (on-chain settled)`,
+ })
+ const valid = await Voucher.verifyVoucher(
+ escrow,
+ chainId,
+ { channelId, cumulativeAmount: cumulativeAmount, signature: payload.signature },
+ channel.authorizedSigner,
+ )
+ if (!valid) throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
+ let captureAmount = uint96(channel.spent > state.settled ? channel.spent : state.settled)
+ if (captureAmount > state.deposit)
+ throw new AmountExceedsDepositError({ reason: 'close capture amount exceeds on-chain deposit' })
+ const pendingCloseStartedAt = BigInt(Math.floor(Date.now() / 1000) || 1)
+ const previousCloseRequestedAt = channel.closeRequestedAt
+ let pendingCloseMarked = false
+ await store.updateChannel(channelId, (current) => {
+ if (!current) return null
+ if (current.finalized) throw new ChannelClosedError({ reason: 'channel is already finalized' })
+ if (current.closeRequestedAt !== 0n)
+ throw new ChannelClosedError({ reason: 'channel has a pending close request' })
+ if (cumulativeAmount < current.spent)
+ throw new VerificationFailedError({
+ reason: `close voucher amount must be >= ${current.spent} (spent)`,
+ })
+ const currentCaptureAmount = uint96(
+ current.spent > state.settled ? current.spent : state.settled,
+ )
+ if (currentCaptureAmount > cumulativeAmount)
+ throw new VerificationFailedError({
+ reason: `close voucher amount must be >= ${currentCaptureAmount} (capture amount)`,
+ })
+ if (currentCaptureAmount > state.deposit)
+ throw new AmountExceedsDepositError({
+ reason: 'close capture amount exceeds on-chain deposit',
+ })
+ captureAmount = currentCaptureAmount
+ pendingCloseMarked = true
+ return { ...current, closeRequestedAt: pendingCloseStartedAt }
+ })
+ const account = parameters.account ?? getClientAccount(client)
+ let txHash: Hex | undefined
+ let receipt: Awaited>
+ try {
+ assertSettlementSender({
+ operation: 'close',
+ channelId,
+ operator: channel.operator,
+ payee: channel.payee,
+ sender: account?.address,
+ })
+ txHash = await Chain.closeOnChain(
+ client,
+ channel.descriptor,
+ cumulativeAmount,
+ captureAmount,
+ payload.signature,
+ escrow,
+ account
+ ? {
+ account,
+ ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
+ ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
+ ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
+ candidateFeeTokens: [channel.token],
+ }
+ : undefined,
+ )
+ receipt = await Chain.waitForSuccessfulReceipt(client, txHash)
+ } catch (error) {
+ if (pendingCloseMarked) {
+ await store.updateChannel(channelId, (current) =>
+ current && current.closeRequestedAt === pendingCloseStartedAt
+ ? { ...current, closeRequestedAt: previousCloseRequestedAt }
+ : current,
+ )
+ }
+ throw error
+ }
+ const closed = Chain.getChannelEvent(receipt, 'ChannelClosed', channelId)
+ const settledToPayee = uint96(closed.args.settledToPayee as bigint)
+ const refundedToPayer = uint96(closed.args.refundedToPayer as bigint)
+ if (settledToPayee > captureAmount || settledToPayee + refundedToPayer > state.deposit)
+ throw new VerificationFailedError({ reason: 'ChannelClosed amounts do not match state' })
+ const updated = await store.updateChannel(channelId, (current) =>
+ current
+ ? {
+ ...current,
+ finalized: true,
+ closeRequestedAt: 0n,
+ deposit: 0n,
+ settledOnChain:
+ captureAmount > current.settledOnChain ? captureAmount : current.settledOnChain,
+ ...(cumulativeAmount > current.highestVoucherAmount
+ ? {
+ highestVoucherAmount: cumulativeAmount,
+ highestVoucher: {
+ channelId,
+ cumulativeAmount: cumulativeAmount,
+ signature: payload.signature,
+ },
+ }
+ : {}),
+ }
+ : current,
+ )
+ return createSessionReceipt({
+ challengeId: challenge.id,
+ channelId,
+ acceptedCumulative: cumulativeAmount,
+ spent: updated?.spent ?? channel.spent,
+ units: updated?.units ?? channel.units,
+ txHash,
+ })
+}
diff --git a/src/tempo/precompile/server/index.ts b/src/tempo/precompile/server/index.ts
new file mode 100644
index 00000000..faa3a59d
--- /dev/null
+++ b/src/tempo/precompile/server/index.ts
@@ -0,0 +1,2 @@
+export * as ChannelOps from './ChannelOps.js'
+export { charge, session, settle } from './Session.js'
diff --git a/src/tempo/precompile/session/SessionManager.test.ts b/src/tempo/precompile/session/SessionManager.test.ts
new file mode 100644
index 00000000..647451b5
--- /dev/null
+++ b/src/tempo/precompile/session/SessionManager.test.ts
@@ -0,0 +1,319 @@
+import { createClient, custom, type Hex } from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { describe, expect, test, vi } from 'vp/test'
+
+import * as Challenge from '../../../Challenge.js'
+import * as Credential from '../../../Credential.js'
+import { formatNeedVoucherEvent, parseEvent } from '../../session/Sse.js'
+import type { NeedVoucherEvent, SessionReceipt } from '../../session/Types.js'
+import type { SessionCredentialPayload } from '../Types.js'
+import { sessionManager } from './SessionManager.js'
+
+const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
+const challengeId = 'test-challenge-1'
+const realm = 'test.example.com'
+const account = privateKeyToAccount(
+ '0xac0974bec39a17e36ba6a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80',
+)
+
+const client = createClient({
+ account,
+ chain: { id: 4217 } as never,
+ transport: custom({
+ async request(args) {
+ if (args.method === 'eth_chainId') return '0x1079'
+ if (args.method === 'eth_getTransactionCount') return '0x0'
+ if (args.method === 'eth_estimateGas') return '0x5208'
+ if (args.method === 'eth_maxPriorityFeePerGas') return '0x1'
+ if (args.method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x1' }
+ throw new Error(`unexpected rpc request: ${args.method}`)
+ },
+ }),
+})
+
+function makeChallenge(overrides: Record = {}): Challenge.Challenge {
+ return Challenge.from({
+ id: challengeId,
+ realm,
+ method: 'tempo',
+ intent: 'session',
+ request: {
+ amount: '1000000',
+ currency: '0x20c0000000000000000000000000000000000001',
+ recipient: '0x742d35cc6634c0532925a3b844bc9e7595f8fe00',
+ decimals: 6,
+ methodDetails: {
+ escrowContract: '0x9d136eEa063eDE5418A6BC7bEafF009bBb6CFa70',
+ chainId: 4217,
+ },
+ ...overrides,
+ },
+ })
+}
+
+function make402Response(challenge?: Challenge.Challenge): Response {
+ const c = challenge ?? makeChallenge()
+ return new Response(null, {
+ status: 402,
+ headers: { 'WWW-Authenticate': Challenge.serialize(c) },
+ })
+}
+
+function makeOkResponse(body?: string): Response {
+ return new Response(body ?? 'ok', { status: 200 })
+}
+
+function makeSseResponse(events: string[]): Response {
+ const body = events.join('')
+ return new Response(body, {
+ status: 200,
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+}
+
+describe('Session', () => {
+ describe('parseEvent round-trip via SSE', () => {
+ test('parses message events from SSE stream', () => {
+ const raw = 'event: message\ndata: hello world\n\n'
+ const event = parseEvent(raw)
+ expect(event).toEqual({ type: 'message', data: 'hello world' })
+ })
+
+ test('parses payment-need-voucher events', () => {
+ const params: NeedVoucherEvent = {
+ channelId,
+ requiredCumulative: '6000000',
+ acceptedCumulative: '5000000',
+ deposit: '10000000',
+ }
+ const raw = formatNeedVoucherEvent(params)
+ const event = parseEvent(raw)
+ expect(event).toEqual({ type: 'payment-need-voucher', data: params })
+ })
+ })
+
+ describe('session creation', () => {
+ test('creates session with initial state', () => {
+ const s = sessionManager({
+ account: '0x0000000000000000000000000000000000000001',
+ maxDeposit: '10',
+ })
+
+ expect(s.channelId).toBeUndefined()
+ expect(s.cumulative).toBe(0n)
+ expect(s.opened).toBe(false)
+ })
+ })
+
+ describe('.fetch()', () => {
+ test('passes through non-402 responses', async () => {
+ const mockFetch = vi.fn().mockResolvedValue(makeOkResponse('hello'))
+
+ const s = sessionManager({
+ account: '0x0000000000000000000000000000000000000001',
+ fetch: mockFetch as typeof globalThis.fetch,
+ })
+
+ const res = await s.fetch('https://api.example.com/data')
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('hello')
+ expect(mockFetch).toHaveBeenCalledOnce()
+ })
+
+ test('throws on 402 without maxDeposit or open channel', async () => {
+ const mockFetch = vi.fn().mockResolvedValue(make402Response())
+
+ const s = sessionManager({
+ account: '0x0000000000000000000000000000000000000001',
+ fetch: mockFetch as typeof globalThis.fetch,
+ })
+
+ await expect(s.fetch('https://api.example.com/data')).rejects.toThrow(
+ 'no `deposit` or `maxDeposit` configured',
+ )
+ })
+ })
+
+ describe('.open()', () => {
+ test('throws when no challenge is available', async () => {
+ const s = sessionManager({
+ account: '0x0000000000000000000000000000000000000001',
+ maxDeposit: '10',
+ })
+
+ await expect(s.open()).rejects.toThrow('No challenge available')
+ })
+ })
+
+ describe('.sse() event parsing', () => {
+ test('yields only message data from SSE stream', async () => {
+ const events = [
+ 'event: message\ndata: chunk1\n\n',
+ 'event: message\ndata: chunk2\n\n',
+ `event: payment-receipt\ndata: ${JSON.stringify({
+ method: 'tempo',
+ intent: 'session',
+ status: 'success',
+ timestamp: '2025-01-01T00:00:00.000Z',
+ reference: channelId,
+ challengeId,
+ channelId,
+ acceptedCumulative: '2000000',
+ spent: '2000000',
+ units: 2,
+ } satisfies SessionReceipt)}\n\n`,
+ ]
+
+ let callCount = 0
+ const mockFetch = vi.fn().mockImplementation(() => {
+ callCount++
+ if (callCount === 1) return Promise.resolve(makeSseResponse(events))
+ return Promise.resolve(makeOkResponse())
+ })
+
+ const s = sessionManager({
+ account: '0x0000000000000000000000000000000000000001',
+ fetch: mockFetch as typeof globalThis.fetch,
+ })
+
+ // Manually set channel state to skip auto-open flow
+ ;(s as any).__test_setChannel?.()
+
+ const receiptCb = vi.fn()
+ const iterable = await s.sse('https://api.example.com/stream', {
+ onReceipt: receiptCb,
+ })
+
+ const messages: string[] = []
+ for await (const msg of iterable) {
+ messages.push(msg)
+ }
+
+ expect(messages).toEqual(['chunk1', 'chunk2'])
+ expect(receiptCb).toHaveBeenCalledOnce()
+ expect(receiptCb.mock.calls[0]![0].units).toBe(2)
+ })
+
+ test('posts precompile SSE top-up vouchers with the channel descriptor', async () => {
+ const needVoucher: NeedVoucherEvent = {
+ channelId,
+ requiredCumulative: '2000000',
+ acceptedCumulative: '1000000',
+ deposit: '10000000',
+ }
+ const events = [
+ 'event: message\ndata: chunk1\n\n',
+ formatNeedVoucherEvent(needVoucher),
+ 'event: message\ndata: chunk2\n\n',
+ ]
+ const postedPayloads: SessionCredentialPayload[] = []
+ let callCount = 0
+ const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
+ callCount++
+ const authorization = new Headers(init?.headers).get('Authorization')
+ if (authorization) {
+ postedPayloads.push(
+ Credential.deserialize(authorization).payload,
+ )
+ }
+ if (callCount === 1) return Promise.resolve(make402Response())
+ if (callCount === 2) return Promise.resolve(makeSseResponse(events))
+ return Promise.resolve(makeOkResponse())
+ })
+
+ const s = sessionManager({
+ account,
+ client,
+ fetch: mockFetch as typeof globalThis.fetch,
+ maxDeposit: '10',
+ })
+
+ const iterable = await s.sse('https://api.example.com/stream')
+ const messages: string[] = []
+ for await (const msg of iterable) messages.push(msg)
+
+ expect(messages).toEqual(['chunk1', 'chunk2'])
+ expect(postedPayloads[0]?.action).toBe('open')
+ expect(postedPayloads[1]?.action).toBe('voucher')
+ const openPayload = postedPayloads[0]
+ const voucherPayload = postedPayloads[1]
+ if (openPayload?.action !== 'open' || voucherPayload?.action !== 'voucher')
+ throw new Error('expected open then voucher payloads')
+ expect(voucherPayload.channelId).toBe(openPayload.channelId)
+ expect(voucherPayload.descriptor).toEqual(openPayload.descriptor)
+ expect(voucherPayload.cumulativeAmount).toBe('2000000')
+ })
+ })
+
+ describe('error handling', () => {
+ test('.sse() silently skips payment-need-voucher when no channel open', async () => {
+ const needVoucher: NeedVoucherEvent = {
+ channelId,
+ requiredCumulative: '2000000',
+ acceptedCumulative: '1000000',
+ deposit: '10000000',
+ }
+
+ const events = [
+ 'event: message\ndata: chunk1\n\n',
+ formatNeedVoucherEvent(needVoucher),
+ 'event: message\ndata: chunk2\n\n',
+ ]
+
+ const mockFetch = vi.fn().mockResolvedValue(makeSseResponse(events))
+
+ const s = sessionManager({
+ account: '0x0000000000000000000000000000000000000001',
+ fetch: mockFetch as typeof globalThis.fetch,
+ })
+
+ const iterable = await s.sse('https://api.example.com/stream')
+
+ const messages: string[] = []
+ for await (const msg of iterable) {
+ messages.push(msg)
+ }
+
+ expect(messages).toEqual(['chunk1', 'chunk2'])
+ expect(mockFetch).toHaveBeenCalledOnce()
+ })
+ })
+
+ describe('.sse() headers normalization', () => {
+ test('preserves Headers instance properties when passed as headers', async () => {
+ const mockFetch = vi.fn().mockResolvedValue(makeSseResponse(['event: message\ndata: ok\n\n']))
+
+ const s = sessionManager({
+ account: '0x0000000000000000000000000000000000000001',
+ fetch: mockFetch as typeof globalThis.fetch,
+ })
+
+ const iterable = await s.sse('https://api.example.com/stream', {
+ headers: new Headers({ 'Content-Type': 'application/json', 'X-Custom': 'value' }),
+ })
+
+ for await (const _ of iterable) {
+ // drain
+ }
+
+ const calledHeaders = new Headers((mockFetch.mock.calls[0]![1] as RequestInit).headers)
+ expect(calledHeaders.get('content-type')).toBe('application/json')
+ expect(calledHeaders.get('x-custom')).toBe('value')
+ expect(calledHeaders.get('accept')).toBe('text/event-stream')
+ })
+ })
+
+ describe('.close()', () => {
+ test('is no-op when not opened', async () => {
+ const mockFetch = vi.fn()
+
+ const s = sessionManager({
+ account: '0x0000000000000000000000000000000000000001',
+ fetch: mockFetch as typeof globalThis.fetch,
+ })
+
+ await s.close()
+ expect(mockFetch).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/src/tempo/precompile/session/SessionManager.ts b/src/tempo/precompile/session/SessionManager.ts
new file mode 100644
index 00000000..432eee90
--- /dev/null
+++ b/src/tempo/precompile/session/SessionManager.ts
@@ -0,0 +1,856 @@
+import type { Hex } from 'ox'
+import { parseUnits, type Address } from 'viem'
+
+import * as Challenge from '../../../Challenge.js'
+import * as Fetch from '../../../client/internal/Fetch.js'
+import * as PaymentCredential from '../../../Credential.js'
+import type * as Account from '../../../viem/Account.js'
+import type * as Client from '../../../viem/Client.js'
+import { deserializeSessionReceipt } from '../../session/Receipt.js'
+import { parseEvent } from '../../session/Sse.js'
+import type { SessionCredentialPayload, SessionReceipt } from '../../session/Types.js'
+import * as Ws from '../../session/Ws.js'
+import type { ChannelEntry } from '../client/ChannelOps.js'
+import { session as sessionPlugin } from '../client/Session.js'
+import { uint96 } from '../Types.js'
+
+type WebSocketConstructor = {
+ new (url: string | URL, protocols?: string | string[]): WebSocket
+}
+
+type ReceiptWaiter = {
+ predicate: (receipt: SessionReceipt) => boolean
+ reject(error: Error): void
+ resolve(receipt: SessionReceipt): void
+}
+
+type CloseReadyWaiter = {
+ reject(error: Error): void
+ resolve(receipt: SessionReceipt): void
+}
+
+const WebSocketReadyState = {
+ CONNECTING: 0,
+ OPEN: 1,
+ CLOSING: 2,
+ CLOSED: 3,
+} as const
+
+// Browser-style WebSocket clients may only initiate close with 1000 or 3000-4999.
+// Keep protocol/policy close codes on the server side and use an app-defined code here.
+const ClientWebSocketProtocolErrorCloseCode = 3008
+
+export type SessionManager = {
+ readonly channelId: Hex.Hex | undefined
+ readonly cumulative: bigint
+ readonly opened: boolean
+
+ open(options?: { deposit?: bigint }): Promise
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise
+ sse(
+ input: RequestInfo | URL,
+ init?: RequestInit & {
+ onReceipt?: ((receipt: SessionReceipt) => void) | undefined
+ signal?: AbortSignal | undefined
+ },
+ ): Promise>
+ ws(
+ input: string | URL,
+ init?: {
+ onReceipt?: ((receipt: SessionReceipt) => void) | undefined
+ protocols?: string | string[] | undefined
+ signal?: AbortSignal | undefined
+ },
+ ): Promise
+ close(): Promise
+}
+
+export type PaymentResponse = Response & {
+ receipt: SessionReceipt | null
+ challenge: Challenge.Challenge | null
+ channelId: Hex.Hex | null
+ cumulative: bigint
+}
+
+/**
+ * Creates a session manager that handles the full client payment lifecycle:
+ * channel open, incremental vouchers, SSE streaming, and channel close.
+ *
+ * Internally delegates to the `session()` method for all
+ * channel state management and credential creation, and to `Fetch.from`
+ * for the 402 challenge/retry flow.
+ *
+ * ## Session resumption
+ *
+ * All channel state is held **in memory**. If the client process restarts,
+ * the session is lost and a new on-chain channel will be opened on the next
+ * request — the previous channel's deposit is orphaned until manually closed.
+ *
+ * Precompile channel identity is descriptor-based. Recovery requires a persisted
+ * channel descriptor; a channel ID alone is not sufficient to resume a TIP-1034
+ * channel.
+ */
+export function sessionManager(parameters: sessionManager.Parameters): SessionManager {
+ const fetchFn = parameters.fetch ?? globalThis.fetch
+ const WebSocketImpl =
+ parameters.webSocket ??
+ (globalThis as typeof globalThis & { WebSocket?: WebSocketConstructor }).WebSocket
+ const maxVoucherCumulative =
+ parameters.maxDeposit !== undefined
+ ? parseUnits(parameters.maxDeposit, parameters.decimals ?? 6)
+ : null
+
+ let channel: ChannelEntry | null = null
+ let lastChallenge: Challenge.Challenge | null = null
+ let lastUrl: RequestInfo | URL | null = null
+ let spent = 0n
+ let activeSocketChallenge: Challenge.Challenge | null = null
+ let activeSocketChannelId: Hex.Hex | null = null
+ let activeSocket: WebSocket | null = null
+ let closeReadyReceipt: SessionReceipt | null = null
+ let closeReadyWaiter: CloseReadyWaiter | null = null
+ let expectedSocketCloseAmount: string | null = null
+ let receiptWaiter: ReceiptWaiter | null = null
+ let wsDeliveredChunks = 0n
+ let wsTickCost = 0n
+
+ const method = sessionPlugin({
+ account: parameters.account,
+ authorizedSigner: parameters.authorizedSigner,
+ getClient: parameters.client ? () => parameters.client! : parameters.getClient,
+ escrow: parameters.escrow,
+ decimals: parameters.decimals,
+ maxDeposit: parameters.maxDeposit,
+ onChannelUpdate(entry) {
+ if (entry.channelId !== channel?.channelId) spent = 0n
+ channel = entry
+ },
+ })
+
+ const wrappedFetch = Fetch.from({
+ fetch: fetchFn,
+ methods: [method],
+ onChallenge: async (challenge, _helpers) => {
+ lastChallenge = challenge
+ return undefined
+ },
+ })
+
+ function updateSpentFromReceipt(receipt: SessionReceipt | null | undefined) {
+ if (!receipt || receipt.channelId !== channel?.channelId) return
+ assertReceiptWithinLocalState(receipt)
+ const next = BigInt(receipt.spent)
+ spent = spent > next ? spent : next
+ }
+
+ function assertReceiptWithinLocalState(receipt: SessionReceipt) {
+ if (!channel || receipt.channelId !== channel.channelId) return
+ const acceptedCumulative = BigInt(receipt.acceptedCumulative)
+ const receiptSpent = BigInt(receipt.spent)
+ if (receiptSpent > acceptedCumulative) {
+ throw new Error('receipt spent exceeds accepted cumulative voucher amount')
+ }
+ if (acceptedCumulative > channel.cumulativeAmount) {
+ throw new Error('receipt accepted cumulative exceeds local voucher state')
+ }
+ if (receiptSpent > channel.cumulativeAmount) {
+ throw new Error('receipt spent exceeds local voucher state')
+ }
+ assertVoucherWithinLocalLimit(acceptedCumulative)
+ assertVoucherWithinLocalLimit(receiptSpent)
+ }
+
+ function waitForReceipt(predicate: (receipt: SessionReceipt) => boolean = () => true) {
+ if (receiptWaiter) throw new Error('receipt wait already in progress')
+ return new Promise((resolve, reject) => {
+ receiptWaiter = { predicate, resolve, reject }
+ })
+ }
+
+ function waitForCloseReady() {
+ if (closeReadyReceipt) return Promise.resolve(closeReadyReceipt)
+ if (closeReadyWaiter) throw new Error('close-ready wait already in progress')
+ return new Promise((resolve, reject) => {
+ closeReadyWaiter = { resolve, reject }
+ })
+ }
+
+ function settleReceipt(receipt: SessionReceipt) {
+ if (!receiptWaiter) return
+ if (!receiptWaiter.predicate(receipt)) return
+ const waiter = receiptWaiter
+ receiptWaiter = null
+ waiter.resolve(receipt)
+ }
+
+ function settleCloseReady(receipt: SessionReceipt) {
+ closeReadyReceipt = receipt
+ if (!closeReadyWaiter) return
+ const waiter = closeReadyWaiter
+ closeReadyWaiter = null
+ waiter.resolve(receipt)
+ }
+
+ function rejectReceipt(error: Error) {
+ if (!receiptWaiter) return
+ const waiter = receiptWaiter
+ receiptWaiter = null
+ waiter.reject(error)
+ }
+
+ function rejectCloseReady(error: Error) {
+ if (!closeReadyWaiter) return
+ const waiter = closeReadyWaiter
+ closeReadyWaiter = null
+ waiter.reject(error)
+ }
+
+ function getFallbackCloseAmount(challenge: Challenge.Challenge, channelId: Hex.Hex): string {
+ if (
+ closeReadyReceipt &&
+ closeReadyReceipt.challengeId === challenge.id &&
+ closeReadyReceipt.channelId === channelId
+ ) {
+ return closeReadyReceipt.spent
+ }
+
+ const cumulative = channel?.channelId === channelId ? channel.cumulativeAmount : 0n
+
+ // For WS sessions, use delivered chunk count × tick cost as a tight spend
+ // estimate. Without this, a socket death before close-ready would cause
+ // the client to sign for the full cumulative voucher authorization —
+ // potentially orders of magnitude more than what was actually consumed.
+ // The estimate may undercount by at most 1 chunk (if the server committed
+ // a charge but the socket died before delivering the message).
+ if (wsTickCost > 0n) {
+ const deliveryEstimate = wsDeliveredChunks * wsTickCost
+ const bestSpent = spent > deliveryEstimate ? spent : deliveryEstimate
+ return (bestSpent > cumulative ? cumulative : bestSpent).toString()
+ }
+
+ // SSE/HTTP: spent is kept in sync by inline receipts, use it directly.
+ return spent.toString()
+ }
+
+ function assertVoucherWithinLocalLimit(cumulativeAmount: bigint) {
+ if (maxVoucherCumulative === null) return
+ if (cumulativeAmount <= maxVoucherCumulative) return
+ throw new Error(
+ `requested voucher amount ${cumulativeAmount} exceeds local maxDeposit ${maxVoucherCumulative}`,
+ )
+ }
+
+ function toPaymentResponse(response: Response): PaymentResponse {
+ const receiptHeader = response.headers.get('Payment-Receipt')
+ const receipt = receiptHeader ? deserializeSessionReceipt(receiptHeader) : null
+ updateSpentFromReceipt(receipt)
+ return Object.assign(response, {
+ receipt,
+ challenge: lastChallenge,
+ channelId: channel?.channelId ?? null,
+ cumulative: channel?.cumulativeAmount ?? 0n,
+ })
+ }
+
+ async function doFetch(input: RequestInfo | URL, init?: RequestInit): Promise {
+ lastUrl = input
+ const response = await wrappedFetch(input, init)
+ return toPaymentResponse(response)
+ }
+
+ function createManagedSocket(socket: WebSocket) {
+ type EventType = 'close' | 'error' | 'message' | 'open'
+ type MessageEvent = { data: string; type: 'message' }
+ type Listener = {
+ once: boolean
+ value: ((event: any) => void) | { handleEvent(event: any): void }
+ }
+ const listeners = new Map>()
+ let emittedClose = false
+ let messageBuffer: MessageEvent[] | null = []
+ let readyState = socket.readyState
+
+ const add = (
+ type: EventType,
+ listener: ((event: any) => void) | { handleEvent(event: any): void },
+ options?: boolean | AddEventListenerOptions,
+ ) => {
+ let set = listeners.get(type)
+ if (!set) {
+ set = new Set()
+ listeners.set(type, set)
+ }
+ set.add({
+ once: typeof options === 'object' ? options.once === true : false,
+ value: listener,
+ })
+ if (type === 'message' && messageBuffer) {
+ const buffered = messageBuffer
+ messageBuffer = null
+ for (const event of buffered) emit('message', event)
+ }
+ }
+
+ const remove = (
+ type: EventType,
+ listener: ((event: any) => void) | { handleEvent(event: any): void },
+ ) => {
+ const set = listeners.get(type)
+ if (!set) return
+ for (const entry of set) {
+ if (entry.value === listener) set.delete(entry)
+ }
+ }
+
+ const emit = (type: EventType, event: any) => {
+ if (type === 'close') {
+ if (emittedClose) return
+ emittedClose = true
+ readyState = WebSocketReadyState.CLOSED
+ messageBuffer = null
+ }
+ if (type === 'open') readyState = WebSocketReadyState.OPEN
+
+ if (type === 'message' && messageBuffer) {
+ messageBuffer.push(event)
+ return
+ }
+
+ const property = `on${type}` as const
+ const handler = (managed as Record)[property]
+ if (typeof handler === 'function') handler(event)
+
+ const set = listeners.get(type)
+ if (!set) return
+ for (const entry of Array.from(set)) {
+ if (typeof entry.value === 'function') entry.value(event)
+ else entry.value.handleEvent(event)
+ if (entry.once) set.delete(entry)
+ }
+ }
+
+ const managed = {
+ addEventListener: add,
+ close(code?: number, reason?: string) {
+ socket.close(code, reason)
+ },
+ get bufferedAmount() {
+ return socket.bufferedAmount
+ },
+ get extensions() {
+ return socket.extensions
+ },
+ on(type: EventType, listener: (...args: any[]) => void) {
+ add(type, listener)
+ },
+ onclose: null as ((event: any) => void) | null,
+ onerror: null as ((event: any) => void) | null,
+ _onmessage: null as ((event: any) => void) | null,
+ get onmessage() {
+ return managed._onmessage
+ },
+ set onmessage(fn: ((event: any) => void) | null) {
+ managed._onmessage = fn
+ if (fn && messageBuffer) {
+ const buffered = messageBuffer
+ messageBuffer = null
+ for (const event of buffered) emit('message', event)
+ }
+ },
+ onopen: null as ((event: any) => void) | null,
+ off(type: EventType, listener: (...args: any[]) => void) {
+ remove(type, listener)
+ },
+ get protocol() {
+ return socket.protocol
+ },
+ get readyState() {
+ return readyState
+ },
+ removeEventListener: remove,
+ send(data: string) {
+ socket.send(data)
+ },
+ get url() {
+ return socket.url
+ },
+ }
+
+ return {
+ emit,
+ socket: managed as unknown as WebSocket,
+ }
+ }
+
+ const self: SessionManager = {
+ get channelId() {
+ return channel?.channelId
+ },
+ get cumulative() {
+ return channel?.cumulativeAmount ?? 0n
+ },
+ get opened() {
+ return channel?.opened ?? false
+ },
+
+ async open(options) {
+ if (channel?.opened) return
+
+ if (!lastChallenge) {
+ throw new Error(
+ 'No challenge available. Make a request first to receive a 402 challenge, or pass a challenge via .fetch()/.sse().',
+ )
+ }
+
+ const deposit = options?.deposit
+ const credential = await method.createCredential({
+ challenge: lastChallenge as never,
+ context: {
+ ...(deposit !== undefined && { depositRaw: deposit.toString() }),
+ },
+ })
+
+ if (!lastUrl) throw new Error('No URL available — call fetch() or sse() before open().')
+ const response = await fetchFn(lastUrl, {
+ method: 'POST',
+ headers: { Authorization: credential },
+ })
+ if (!response.ok) {
+ const body = await response.text().catch(() => '')
+ const wwwAuth = response.headers.get('WWW-Authenticate') ?? ''
+ throw new Error(
+ `Open request failed with status ${response.status}${body ? `: ${body}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`,
+ )
+ }
+ },
+
+ fetch: doFetch,
+
+ async sse(input, init) {
+ const { onReceipt, signal, ...fetchInit } = init ?? {}
+
+ const sseInit = {
+ ...fetchInit,
+ headers: {
+ ...Fetch.normalizeHeaders(fetchInit.headers),
+ Accept: 'text/event-stream',
+ },
+ ...(signal ? { signal } : {}),
+ }
+
+ const response = await doFetch(input, sseInit)
+
+ // Snapshot the challenge at SSE open time so concurrent
+ // calls don't overwrite it.
+ const sseChallenge = lastChallenge
+
+ if (!response.body) throw new Error('Response has no body.')
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+
+ async function* iterate(): AsyncGenerator {
+ let buffer = ''
+
+ try {
+ while (true) {
+ if (signal?.aborted) break
+
+ const { done, value } = await reader.read()
+ if (done) break
+
+ buffer += decoder.decode(value, { stream: true })
+
+ const parts = buffer.split('\n\n')
+ buffer = parts.pop()!
+
+ for (const part of parts) {
+ if (!part.trim()) continue
+
+ const event = parseEvent(part)
+ if (!event) continue
+
+ switch (event.type) {
+ case 'message':
+ yield event.data
+ break
+
+ case 'payment-need-voucher': {
+ if (!channel || !sseChallenge) break
+ const required = BigInt(event.data.requiredCumulative)
+ assertVoucherWithinLocalLimit(required)
+ channel.cumulativeAmount =
+ channel.cumulativeAmount > required
+ ? channel.cumulativeAmount
+ : uint96(required)
+
+ const credential = await method.createCredential({
+ challenge: sseChallenge as never,
+ context: {
+ action: 'voucher',
+ channelId: channel.channelId,
+ descriptor: channel.descriptor,
+ cumulativeAmountRaw: channel.cumulativeAmount.toString(),
+ },
+ })
+ const voucherResponse = await fetchFn(input, {
+ method: 'POST',
+ headers: { Authorization: credential },
+ })
+ if (!voucherResponse.ok) {
+ throw new Error(`Voucher POST failed with status ${voucherResponse.status}`)
+ }
+ break
+ }
+
+ case 'payment-receipt':
+ updateSpentFromReceipt(event.data)
+ onReceipt?.(event.data)
+ break
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock()
+ }
+ }
+
+ return iterate()
+ },
+
+ async ws(input, init) {
+ if (!WebSocketImpl) {
+ throw new Error(
+ 'No WebSocket implementation available. Pass `webSocket` to sessionManager() in this runtime.',
+ )
+ }
+
+ const { onReceipt, protocols, signal } = init ?? {}
+ const wsUrl = new URL(input.toString())
+ const httpUrl = new URL(wsUrl.toString())
+ if (httpUrl.protocol === 'ws:') httpUrl.protocol = 'http:'
+ if (httpUrl.protocol === 'wss:') httpUrl.protocol = 'https:'
+
+ lastUrl = httpUrl.toString()
+ const probe = await fetchFn(httpUrl, signal ? { signal } : undefined)
+ if (probe.status !== 402) {
+ throw new Error(
+ `Expected a 402 payment challenge from ${httpUrl}, received ${probe.status} instead.`,
+ )
+ }
+
+ const challenge = Challenge.fromResponseList(probe).find(
+ (item) => item.method === method.name && item.intent === method.intent,
+ )
+ if (!challenge) {
+ throw new Error(
+ 'No payment challenge received from HTTP endpoint for this WebSocket URL. The server may not require payment or did not advertise a challenge.',
+ )
+ }
+ lastChallenge = challenge
+
+ const credential = await method.createCredential({
+ challenge: challenge as never,
+ context: {},
+ })
+
+ closeReadyReceipt = null
+ activeSocketChallenge = challenge
+ wsDeliveredChunks = 0n
+ wsTickCost = BigInt(challenge.request.amount as string)
+ const openCredential = PaymentCredential.deserialize(credential)
+ activeSocketChannelId = openCredential.payload.channelId
+ const rawSocket = new WebSocketImpl(wsUrl, protocols)
+ activeSocket = rawSocket
+ const managedSocket = createManagedSocket(rawSocket)
+
+ const failSocketFlow = (message: string) => {
+ rejectReceipt(new Error(message))
+ rejectCloseReady(new Error(message))
+ if (
+ rawSocket.readyState === WebSocketReadyState.CONNECTING ||
+ rawSocket.readyState === WebSocketReadyState.OPEN
+ ) {
+ rawSocket.close(ClientWebSocketProtocolErrorCloseCode, message)
+ }
+ }
+
+ const isExpectedReceipt = (receipt: SessionReceipt) =>
+ receipt.challengeId === challenge.id && receipt.channelId === activeSocketChannelId
+
+ const socketOpened = new Promise((resolve, reject) => {
+ const onOpen = () => {
+ rawSocket.removeEventListener('error', onError)
+ managedSocket.emit('open', { type: 'open' })
+ resolve()
+ }
+ const onError = () => {
+ rawSocket.removeEventListener('open', onOpen)
+ reject(new Error(`WebSocket connection to ${wsUrl} failed to open.`))
+ }
+ rawSocket.addEventListener('open', onOpen, { once: true })
+ rawSocket.addEventListener('error', onError, { once: true })
+ })
+
+ rawSocket.addEventListener('close', (event) => {
+ if (activeSocket === rawSocket) activeSocket = null
+ if (activeSocketChallenge === challenge) activeSocketChallenge = null
+ if (activeSocketChannelId === openCredential.payload.channelId) activeSocketChannelId = null
+ expectedSocketCloseAmount = null
+ rejectReceipt(new Error('WebSocket closed before the payment flow completed.'))
+ rejectCloseReady(new Error('WebSocket closed before the payment flow completed.'))
+ managedSocket.emit('close', {
+ code: (event as CloseEvent).code ?? 1000,
+ reason: (event as CloseEvent).reason ?? '',
+ type: 'close',
+ wasClean: true,
+ })
+ })
+
+ rawSocket.addEventListener('error', () => {
+ managedSocket.emit('error', { type: 'error' })
+ })
+
+ rawSocket.addEventListener('message', async (event) => {
+ const raw = typeof event.data === 'string' ? event.data : undefined
+ if (!raw) return
+
+ const message = Ws.parseMessage(raw)
+ if (!message) {
+ managedSocket.emit('message', { data: raw, type: 'message' })
+ return
+ }
+
+ switch (message.mpp) {
+ case 'authorization':
+ break
+ case 'message':
+ wsDeliveredChunks += 1n
+ managedSocket.emit('message', { data: message.data, type: 'message' })
+ break
+ case 'payment-close-ready':
+ if (!isExpectedReceipt(message.data)) {
+ failSocketFlow('received mismatched payment-close-ready frame')
+ break
+ }
+ if (BigInt(message.data.spent) > (channel?.cumulativeAmount ?? 0n)) {
+ failSocketFlow('received payment-close-ready beyond local voucher state')
+ break
+ }
+ updateSpentFromReceipt(message.data)
+ onReceipt?.(message.data)
+ settleCloseReady(message.data)
+ managedSocket.emit('close', { code: 1000, reason: 'stream complete', type: 'close' })
+ break
+ case 'payment-error':
+ rejectReceipt(new Error(message.message))
+ rejectCloseReady(new Error(message.message))
+ break
+ case 'payment-need-voucher': {
+ if (message.data.channelId !== activeSocketChannelId) {
+ failSocketFlow('received mismatched payment-need-voucher frame')
+ break
+ }
+ const required = BigInt(message.data.requiredCumulative)
+ try {
+ assertVoucherWithinLocalLimit(required)
+ } catch (error) {
+ failSocketFlow(
+ error instanceof Error
+ ? error.message
+ : 'requested voucher amount exceeds local maxDeposit',
+ )
+ break
+ }
+ const nextCumulative =
+ (channel?.cumulativeAmount ?? 0n) > required
+ ? (channel?.cumulativeAmount ?? 0n)
+ : required
+ if (channel?.channelId === activeSocketChannelId)
+ channel.cumulativeAmount = uint96(nextCumulative)
+
+ const voucher = await method.createCredential({
+ challenge: challenge as never,
+ context: {
+ action: 'voucher',
+ channelId: activeSocketChannelId,
+ descriptor: channel?.descriptor,
+ cumulativeAmountRaw: nextCumulative.toString(),
+ },
+ })
+ rawSocket.send(Ws.formatAuthorizationMessage(voucher))
+ break
+ }
+ case 'payment-receipt':
+ if (!isExpectedReceipt(message.data)) {
+ failSocketFlow('received mismatched payment-receipt frame')
+ break
+ }
+ if (
+ expectedSocketCloseAmount !== null &&
+ Boolean(message.data.txHash) &&
+ (message.data.acceptedCumulative !== expectedSocketCloseAmount ||
+ message.data.spent !== expectedSocketCloseAmount)
+ ) {
+ failSocketFlow('received mismatched payment-close receipt frame')
+ break
+ }
+ updateSpentFromReceipt(message.data)
+ onReceipt?.(message.data)
+ settleReceipt(message.data)
+ break
+ }
+ })
+
+ if (signal) {
+ signal.addEventListener(
+ 'abort',
+ () => {
+ rejectReceipt(new Error('WebSocket payment flow aborted.'))
+ rejectCloseReady(new Error('WebSocket payment flow aborted.'))
+ rawSocket.close()
+ },
+ { once: true },
+ )
+ }
+
+ await socketOpened
+ rawSocket.send(Ws.formatAuthorizationMessage(credential))
+ await waitForReceipt()
+ return managedSocket.socket
+ },
+
+ async close() {
+ const closeChallenge = activeSocketChallenge ?? lastChallenge
+ const closeChannelId = activeSocketChannelId ?? channel?.channelId
+
+ if (!channel?.opened) return undefined
+
+ if (!closeChallenge) {
+ throw new Error(
+ 'Cannot close session: no challenge available. This usually means close() was called on a SessionManager instance that was recreated after the session was opened. Use the same SessionManager instance that opened the session, or make a request first to receive a fresh 402 challenge.',
+ )
+ }
+ if (!closeChannelId) {
+ throw new Error(
+ 'Cannot close session: no channel ID available. The session may not have been fully opened.',
+ )
+ }
+
+ if (activeSocket?.readyState === WebSocketReadyState.OPEN) {
+ const ready =
+ closeReadyReceipt ??
+ (await (async () => {
+ activeSocket.send(Ws.formatCloseRequestMessage())
+ return waitForCloseReady()
+ })())
+ const readySpent = BigInt(ready.spent)
+ if (readySpent > (channel.cumulativeAmount > spent ? channel.cumulativeAmount : spent)) {
+ throw new Error('close-ready spent exceeds local voucher state')
+ }
+
+ const credential = await method.createCredential({
+ challenge: closeChallenge as never,
+ context: {
+ action: 'close',
+ channelId: closeChannelId,
+ descriptor: channel.descriptor,
+ cumulativeAmountRaw: readySpent.toString(),
+ },
+ })
+
+ const expectedCloseAmount = readySpent.toString()
+ expectedSocketCloseAmount = expectedCloseAmount
+ try {
+ const pendingReceipt = waitForReceipt(
+ (receipt) =>
+ Boolean(receipt.txHash) &&
+ receipt.challengeId === closeChallenge.id &&
+ receipt.channelId === closeChannelId &&
+ receipt.acceptedCumulative === expectedCloseAmount &&
+ receipt.spent === expectedCloseAmount,
+ )
+ activeSocket.send(Ws.formatAuthorizationMessage(credential))
+ const receipt = await pendingReceipt
+ activeSocket.close()
+ closeReadyReceipt = null
+ return receipt
+ } finally {
+ expectedSocketCloseAmount = null
+ }
+ }
+
+ const credential = await method.createCredential({
+ challenge: closeChallenge as never,
+ context: {
+ action: 'close',
+ channelId: closeChannelId,
+ descriptor: channel.descriptor,
+ cumulativeAmountRaw: (() => {
+ const closeAmount = BigInt(getFallbackCloseAmount(closeChallenge, closeChannelId))
+ if (closeAmount > channel.cumulativeAmount) {
+ throw new Error('fallback close amount exceeds local voucher state')
+ }
+ assertVoucherWithinLocalLimit(closeAmount)
+ return closeAmount.toString()
+ })(),
+ },
+ })
+
+ if (!lastUrl) {
+ throw new Error(
+ 'Cannot close session: no URL available. This usually means close() was called on a SessionManager instance that was recreated after the session was opened. Use the same SessionManager instance that opened the session, or call fetch()/sse() before close().',
+ )
+ }
+
+ const response = await fetchFn(lastUrl, {
+ method: 'POST',
+ headers: { Authorization: credential },
+ })
+ if (!response.ok) {
+ const body = await response.text().catch(() => '')
+ const detail = (() => {
+ if (!body) return ''
+ if (!response.headers.get('Content-Type')?.includes('application/problem+json')) {
+ return body
+ }
+ try {
+ const problem = JSON.parse(body) as { detail?: string }
+ return problem.detail ?? body
+ } catch {
+ return body
+ }
+ })()
+ const wwwAuth = response.headers.get('WWW-Authenticate') ?? ''
+ throw new Error(
+ `Close request failed with status ${response.status}${detail ? `: ${detail}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`,
+ )
+ }
+ const receiptHeader = response.headers.get('Payment-Receipt')
+ const receipt = receiptHeader ? deserializeSessionReceipt(receiptHeader) : undefined
+
+ return receipt
+ },
+ }
+
+ return self
+}
+
+export declare namespace sessionManager {
+ type Parameters = Account.getResolver.Parameters &
+ Client.getResolver.Parameters & {
+ /** Address authorized to sign vouchers. Defaults to the account access key address when available, otherwise the account address. */
+ authorizedSigner?: Address | undefined
+ /** Viem client instance. Shorthand for `getClient: () => client`. */
+ client?: import('viem').Client | undefined
+ /** Token decimals used to convert `maxDeposit` to raw units. Defaults to `6`. */
+ decimals?: number | undefined
+ /** TIP20EscrowChannel precompile address override. */
+ escrow?: Address | undefined
+ fetch?: typeof globalThis.fetch | undefined
+ /** Maximum deposit in human-readable units (e.g. `'10'` for 10 tokens). Converted to raw units via `decimals`. */
+ maxDeposit?: string | undefined
+ /** Optional websocket constructor for runtimes without a global WebSocket. */
+ webSocket?: WebSocketConstructor | undefined
+ }
+}
diff --git a/src/tempo/precompile/session/index.ts b/src/tempo/precompile/session/index.ts
new file mode 100644
index 00000000..c47b1da0
--- /dev/null
+++ b/src/tempo/precompile/session/index.ts
@@ -0,0 +1 @@
+export { sessionManager } from './SessionManager.js'
diff --git a/src/tempo/server/AtomicStore.test-d.ts b/src/tempo/server/AtomicStore.test-d.ts
index 255d6b68..5b4c57ab 100644
--- a/src/tempo/server/AtomicStore.test-d.ts
+++ b/src/tempo/server/AtomicStore.test-d.ts
@@ -2,6 +2,7 @@ import { expectTypeOf, test } from 'vp/test'
import { tempo } from '../../server/index.js'
import * as Store from '../../Store.js'
+import * as Tempo from '../index.js'
test('tempo.charge store parameter requires AtomicStore', () => {
type ChargeParameters = NonNullable[0]>
@@ -43,3 +44,22 @@ test('tempo.session store parameter requires AtomicStore', () => {
tempo.session({ store: nonAtomic })
tempo.session({ store: Store.memory() })
})
+
+test('tempo precompile server session store parameter requires AtomicStore', () => {
+ type PrecompileSessionParameters = NonNullable<
+ Parameters[0]
+ >
+ expectTypeOf().toEqualTypeOf<
+ Store.AtomicStore | undefined
+ >()
+
+ const nonAtomic = Store.from({
+ get: async () => null,
+ put: async () => {},
+ delete: async () => {},
+ })
+
+ // @ts-expect-error — precompile session state updates require AtomicStore
+ Tempo.Precompile.Server.session({ store: nonAtomic })
+ Tempo.Precompile.Server.session({ store: Store.memory() })
+})
diff --git a/src/tempo/server/Authorize.ts b/src/tempo/server/Authorize.ts
new file mode 100644
index 00000000..1394d0c9
--- /dev/null
+++ b/src/tempo/server/Authorize.ts
@@ -0,0 +1,478 @@
+import type { Account, Address, Client as ViemClient, Hex } from 'viem'
+import { tempo as tempo_chain } from 'viem/chains'
+import { Transaction } from 'viem/tempo'
+
+import {
+ AmountExceedsDepositError,
+ ChannelClosedError,
+ ChannelNotFoundError,
+ VerificationFailedError,
+} from '../../Errors.js'
+import type { NoExtraKeys } from '../../internal/types.js'
+import * as Method from '../../Method.js'
+import type * as Receipt_ from '../../Receipt.js'
+import * as Store from '../../Store.js'
+import * as Client from '../../viem/Client.js'
+import * as AuthorizeReceipt from '../authorize/Receipt.js'
+import * as AuthorizeStore from '../authorize/Store.js'
+import type { Authorization, Receipt } from '../authorize/Types.js'
+import * as AccountResolver from '../internal/account.js'
+import * as defaults from '../internal/defaults.js'
+import * as FeePayer from '../internal/fee-payer.js'
+import type * as types from '../internal/types.js'
+import * as Methods from '../Methods.js'
+import * as Chain from '../precompile/Chain.js'
+import * as Channel from '../precompile/Channel.js'
+import { tip20ChannelEscrow } from '../precompile/Constants.js'
+import { uint96 } from '../precompile/Types.js'
+import * as Voucher from '../precompile/Voucher.js'
+
+type AuthorizeRequest = ReturnType
+type AuthorizeMethodDetails = NonNullable
+
+/** Creates a Tempo authorize method for deferred TIP-20 captures. */
+export function authorize(
+ p: NoExtraKeys,
+) {
+ const parameters = p as parameters
+ const {
+ amount,
+ currency = defaults.resolveCurrency(parameters),
+ decimals = defaults.decimals,
+ description,
+ externalId,
+ store: rawStore = Store.memory(),
+ } = parameters
+ const { account, recipient, feePayer, feePayerUrl } = AccountResolver.resolve(parameters)
+ const operator = addressOf(parameters.operator ?? account)
+ const authorizedSigner = addressOf(parameters.authorizedSigner ?? account)
+ if (!recipient) throw new Error('tempo.authorize() requires a recipient.')
+ if (!operator) throw new Error('tempo.authorize() requires an operator or account.')
+ if (!authorizedSigner)
+ throw new Error('tempo.authorize() requires an authorizedSigner or account.')
+
+ const store = AuthorizeStore.fromStore(rawStore, { keyPrefix: parameters.keyPrefix })
+ const getClient = Client.getResolver({
+ chain: tempo_chain,
+ feePayerUrl,
+ getClient: parameters.getClient,
+ rpcUrl: defaults.rpcUrl,
+ })
+
+ type Defaults = authorize.DeriveDefaults
+ return Method.toServer(Methods.authorize, {
+ defaults: {
+ amount,
+ authorizedSigner,
+ currency,
+ decimals,
+ description,
+ externalId,
+ operator,
+ recipient,
+ } as unknown as Defaults,
+
+ async request({ credential, request }) {
+ const chainId = await (async () => {
+ if (request.chainId) return request.chainId
+ if (parameters.chainId) return parameters.chainId
+ return (await getClient({})).chain?.id
+ })()
+ if (!chainId) throw new Error('No chainId configured for tempo.authorize().')
+
+ const client = await getClient({ chainId })
+ if (client.chain?.id !== chainId)
+ throw new Error(`Client not configured with chainId ${chainId}.`)
+
+ const resolvedFeePayer = (() => {
+ if (request.feePayer === false) return credential ? false : undefined
+ const requested = typeof request.feePayer === 'object' ? request.feePayer : feePayer
+ if (credential) return requested ?? (feePayerUrl ? true : undefined)
+ if (requested ?? feePayerUrl) return true
+ return undefined
+ })()
+
+ return {
+ ...request,
+ chainId,
+ escrowContract: request.escrowContract ?? parameters.escrowContract ?? tip20ChannelEscrow,
+ feePayer: resolvedFeePayer,
+ }
+ },
+
+ async verify({ credential, request }) {
+ const context = parseAuthorizeVerification({
+ payload: credential.payload,
+ request,
+ })
+ const { amount, channelId, chainId, escrow, methodDetails, payload, resolvedRequest } =
+ context
+ const client = await getClient({ chainId })
+ const result = await Chain.broadcastOpenTransaction({
+ async beforeBroadcast({ openDeposit }) {
+ if (openDeposit !== amount)
+ throw new VerificationFailedError({
+ reason: 'TIP-1034 authorize deposit does not match challenge',
+ })
+ if (await store.get(channelId))
+ throw new VerificationFailedError({
+ reason: 'authorization credential has already been used',
+ })
+ },
+ challengeExpires: credential.challenge.expires,
+ chainId,
+ client,
+ escrowContract: escrow,
+ expectedAuthorizedSigner: methodDetails.authorizedSigner as Address,
+ expectedChannelId: channelId,
+ expectedCurrency: resolvedRequest.currency as Address,
+ expectedExpiringNonceHash: context.expiringNonceHash,
+ expectedOperator: methodDetails.operator as Address,
+ expectedPayee: resolvedRequest.recipient as Address,
+ expectedPayer: context.payer,
+ feePayer: methodDetails.feePayer === true ? feePayer : undefined,
+ feePayerPolicy: parameters.feePayerPolicy,
+ serializedTransaction: payload.transaction as Hex,
+ })
+ if (result.state.deposit !== amount || result.state.settled !== 0n)
+ throw new VerificationFailedError({
+ reason: 'authorize channel state does not match challenge',
+ })
+
+ const authorization: Authorization = {
+ amount: amount.toString(),
+ capturedAmount: '0',
+ challengeId: credential.challenge.id,
+ channel: {
+ chainId,
+ descriptor: result.descriptor,
+ escrow,
+ id: channelId,
+ },
+ openTxHash: result.txHash,
+ status: 'authorized',
+ }
+ const created = await store.create(authorization)
+ if (created === 'exists')
+ throw new VerificationFailedError({
+ reason: 'authorization credential has already been used',
+ })
+
+ return {
+ response: Response.json(
+ {
+ authorization: toMetadata(authorization),
+ },
+ {
+ headers: { 'Cache-Control': 'no-store' },
+ },
+ ),
+ } as never
+ },
+ })
+}
+
+function parseAuthorizeVerification(parameters: { payload: unknown; request: unknown }) {
+ const parsedPayload = Methods.authorize.schema.credential.payload.safeParse(parameters.payload)
+ if (!parsedPayload.success)
+ throw new VerificationFailedError({ reason: 'authorize credential payload is invalid' })
+ const parsed = Methods.authorize.schema.request.safeParse(parameters.request)
+ if (!parsed.success) throw new VerificationFailedError({ reason: 'authorize request is invalid' })
+
+ const payload = parsedPayload.data
+ const resolvedRequest = parsed.data
+ const methodDetails = resolvedRequest.methodDetails as AuthorizeMethodDetails
+ const chainId = methodDetails.chainId
+ if (!chainId) throw new VerificationFailedError({ reason: 'authorize chainId is missing' })
+
+ const transaction = deserializeAuthorizeTransaction(payload.transaction)
+ if (!transaction.from)
+ throw new VerificationFailedError({ reason: 'authorize transaction has no payer' })
+
+ return {
+ amount: uint96(BigInt(resolvedRequest.amount)),
+ channelId: payload.channelId as Hex,
+ chainId,
+ escrow: methodDetails.escrowContract ?? tip20ChannelEscrow,
+ expiringNonceHash: Channel.computeExpiringNonceHash(
+ transaction as Channel.ExpiringNonceTransaction,
+ { sender: transaction.from },
+ ),
+ methodDetails,
+ payer: transaction.from,
+ payload,
+ resolvedRequest,
+ }
+}
+
+function deserializeAuthorizeTransaction(transaction: string) {
+ try {
+ return Transaction.deserialize(transaction as Transaction.TransactionSerializedTempo)
+ } catch {
+ throw new VerificationFailedError({ reason: 'authorize transaction is invalid' })
+ }
+}
+
+/** Capture an amount against a Tempo authorization. */
+export async function capture(
+ store_: Store.AtomicStore | AuthorizeStore.AuthorizationStore,
+ client: ViemClient,
+ authorizationId: Hex,
+ options: capture.Options,
+): Promise {
+ const store =
+ 'create' in store_ ? store_ : AuthorizeStore.fromStore(store_, { keyPrefix: options.keyPrefix })
+ const authorization = await store.get(authorizationId)
+ if (!authorization) throw new ChannelNotFoundError({ reason: 'authorization not found' })
+ const existing = AuthorizeStore.getCaptureReceipt(authorization, options.idempotencyKey)
+ if (existing) return existing
+ assertActive(authorization)
+
+ const delta = uint96(BigInt(options.amount))
+ const previous = BigInt(authorization.capturedAmount)
+ const cumulative = uint96(previous + delta)
+ const limit = BigInt(authorization.amount)
+ if (cumulative > limit)
+ throw new AmountExceedsDepositError({ reason: 'capture exceeds authorized amount' })
+
+ const state = await Chain.getChannelState(
+ client,
+ authorization.channel.id,
+ authorization.channel.escrow,
+ )
+ if (state.closeRequestedAt !== 0)
+ throw new ChannelClosedError({ reason: 'authorization has a pending close request' })
+ if (cumulative <= state.settled)
+ throw new VerificationFailedError({
+ reason: 'capture amount must exceed on-chain settled amount',
+ })
+
+ const signer = options.authorizedSigner ?? options.account ?? client.account
+ if (!signer) throw new Error('Cannot capture authorization: no signer account available.')
+ const signature = await Voucher.signVoucher(
+ client,
+ signer,
+ { channelId: authorization.channel.id, cumulativeAmount: cumulative },
+ authorization.channel.escrow,
+ authorization.channel.chainId,
+ authorization.channel.descriptor.authorizedSigner,
+ )
+
+ const account = options.account ?? client.account
+ const txHash = options.close
+ ? await Chain.closeOnChain(
+ client,
+ authorization.channel.descriptor,
+ cumulative,
+ cumulative,
+ signature,
+ authorization.channel.escrow,
+ account
+ ? {
+ account,
+ ...(options.feePayer ? { feePayer: options.feePayer } : {}),
+ ...(options.feePayerPolicy ? { feePayerPolicy: options.feePayerPolicy } : {}),
+ ...(options.feeToken ? { feeToken: options.feeToken } : {}),
+ candidateFeeTokens: options.candidateFeeTokens ?? [
+ authorization.channel.descriptor.token,
+ ],
+ }
+ : undefined,
+ )
+ : await Chain.settleOnChain(
+ client,
+ authorization.channel.descriptor,
+ cumulative,
+ signature,
+ authorization.channel.escrow,
+ account
+ ? {
+ account,
+ ...(options.feePayer ? { feePayer: options.feePayer } : {}),
+ ...(options.feePayerPolicy ? { feePayerPolicy: options.feePayerPolicy } : {}),
+ ...(options.feeToken ? { feeToken: options.feeToken } : {}),
+ candidateFeeTokens: options.candidateFeeTokens ?? [
+ authorization.channel.descriptor.token,
+ ],
+ }
+ : undefined,
+ )
+ const receipt = await Chain.waitForSuccessfulReceipt(client, txHash)
+ const event = Chain.getChannelEvent(
+ receipt,
+ options.close ? 'ChannelClosed' : 'Settled',
+ authorization.channel.id,
+ )
+ const newCaptured = options.close
+ ? uint96(event.args.settledToPayee as bigint)
+ : uint96(event.args.newSettled as bigint)
+ if (newCaptured < cumulative)
+ throw new VerificationFailedError({ reason: 'capture receipt settled less than requested' })
+
+ const captureReceipt = AuthorizeReceipt.create({
+ authorizationId: authorization.channel.id,
+ capturedAmount: newCaptured,
+ delta: newCaptured - previous,
+ reference: txHash,
+ })
+
+ return store.update(authorization.channel.id, (current) => {
+ if (!current) throw new ChannelNotFoundError({ reason: 'authorization not found' })
+ const existingReceipt = AuthorizeStore.getCaptureReceipt(current, options.idempotencyKey)
+ if (existingReceipt) return { op: 'noop', result: existingReceipt }
+
+ const currentCaptured = BigInt(current.capturedAmount)
+ const nextCaptured = currentCaptured > newCaptured ? currentCaptured : newCaptured
+ const receipts = options.idempotencyKey
+ ? {
+ ...(current.captureReceipts ?? {}),
+ [options.idempotencyKey]: captureReceipt,
+ }
+ : current.captureReceipts
+ return {
+ op: 'set',
+ value: {
+ ...current,
+ capturedAmount: nextCaptured.toString(),
+ ...(receipts ? { captureReceipts: receipts } : {}),
+ ...(options.close ? { status: 'closed' as const } : {}),
+ },
+ result: captureReceipt,
+ }
+ })
+}
+
+/** Void a Tempo authorization without increasing captured value. */
+export async function voidAuthorization(
+ store_: Store.AtomicStore | AuthorizeStore.AuthorizationStore,
+ client: ViemClient,
+ authorizationId: Hex,
+ options: voidAuthorization.Options = {},
+): Promise<{ authorizationId: Hex; reference: Hex; releasedAmount: string; status: 'voided' }> {
+ const store =
+ 'create' in store_ ? store_ : AuthorizeStore.fromStore(store_, { keyPrefix: options.keyPrefix })
+ const authorization = await store.get(authorizationId)
+ if (!authorization) throw new ChannelNotFoundError({ reason: 'authorization not found' })
+ assertActive(authorization)
+
+ const captured = uint96(BigInt(authorization.capturedAmount))
+ const account = options.account ?? client.account
+ const txHash = await Chain.closeOnChain(
+ client,
+ authorization.channel.descriptor,
+ captured,
+ captured,
+ '0x',
+ authorization.channel.escrow,
+ account
+ ? {
+ account,
+ ...(options.feePayer ? { feePayer: options.feePayer } : {}),
+ ...(options.feePayerPolicy ? { feePayerPolicy: options.feePayerPolicy } : {}),
+ ...(options.feeToken ? { feeToken: options.feeToken } : {}),
+ candidateFeeTokens: options.candidateFeeTokens ?? [
+ authorization.channel.descriptor.token,
+ ],
+ }
+ : undefined,
+ )
+ const receipt = await Chain.waitForSuccessfulReceipt(client, txHash)
+ const closed = Chain.getChannelEvent(receipt, 'ChannelClosed', authorization.channel.id)
+ const releasedAmount = uint96(closed.args.refundedToPayer as bigint)
+ await store.update(authorization.channel.id, (current) => {
+ if (!current) throw new ChannelNotFoundError({ reason: 'authorization not found' })
+ return {
+ op: 'set',
+ value: { ...current, status: 'voided' },
+ result: undefined,
+ }
+ })
+ return {
+ authorizationId: authorization.channel.id,
+ reference: txHash,
+ releasedAmount: releasedAmount.toString(),
+ status: 'voided',
+ }
+}
+
+function addressOf(value: Account | Address | undefined): Address | undefined {
+ if (!value) return undefined
+ return typeof value === 'object' ? value.address : value
+}
+
+function assertActive(authorization: Authorization) {
+ if (authorization.status !== 'authorized')
+ throw new ChannelClosedError({ reason: `authorization is ${authorization.status}` })
+}
+
+function toMetadata(authorization: Authorization) {
+ const capturedAmount = BigInt(authorization.capturedAmount)
+ const amount = BigInt(authorization.amount)
+ return {
+ id: authorization.channel.id,
+ method: 'tempo',
+ status: authorization.status,
+ amount: authorization.amount,
+ capturedAmount: authorization.capturedAmount,
+ remainingAmount: (amount - capturedAmount).toString(),
+ currency: authorization.channel.descriptor.token,
+ recipient: authorization.channel.descriptor.payee,
+ operator: authorization.channel.descriptor.operator,
+ authorizedSigner: authorization.channel.descriptor.authorizedSigner,
+ reference: authorization.openTxHash,
+ }
+}
+
+export declare namespace authorize {
+ type Defaults = Method.RequestDefaults
+
+ type FeePayerPolicy = Partial
+
+ type Parameters = {
+ account?: Account | Address | undefined
+ authorizedSigner?: Account | Address | undefined
+ chainId?: number | undefined
+ escrowContract?: Address | undefined
+ feePayer?: Account | string | true | undefined
+ feePayerPolicy?: FeePayerPolicy | undefined
+ operator?: Account | Address | undefined
+ recipient?: Address | undefined
+ store?: Store.AtomicStore | undefined
+ keyPrefix?: string | undefined
+ } & Client.getResolver.Parameters &
+ Defaults
+
+ type DeriveDefaults = types.DeriveDefaults &
+ (parameters extends { account: Account | Address }
+ ? { authorizedSigner: Address; operator: Address }
+ : {}) & { decimals: number }
+}
+
+export declare namespace capture {
+ type Options = {
+ account?: Account | undefined
+ amount: string | bigint
+ authorizedSigner?: Account | undefined
+ candidateFeeTokens?: readonly Address[] | undefined
+ close?: boolean | undefined
+ feePayer?: Account | undefined
+ feePayerPolicy?: authorize.FeePayerPolicy | undefined
+ feeToken?: Address | undefined
+ idempotencyKey?: string | undefined
+ keyPrefix?: string | undefined
+ }
+}
+
+export declare namespace voidAuthorization {
+ type Options = {
+ account?: Account | undefined
+ candidateFeeTokens?: readonly Address[] | undefined
+ feePayer?: Account | undefined
+ feePayerPolicy?: authorize.FeePayerPolicy | undefined
+ feeToken?: Address | undefined
+ keyPrefix?: string | undefined
+ }
+}
+
+const _receiptTypeCheck = undefined as unknown as Receipt extends Receipt_.Receipt ? true : never
+void _receiptTypeCheck
diff --git a/src/tempo/server/Methods.ts b/src/tempo/server/Methods.ts
index d8ab70a4..ff0fcb17 100644
--- a/src/tempo/server/Methods.ts
+++ b/src/tempo/server/Methods.ts
@@ -1,4 +1,10 @@
+import * as Precompile_ from '../precompile/index.js'
import * as Ws_ from '../session/Ws.js'
+import {
+ authorize as authorize_,
+ capture as capture_,
+ voidAuthorization as voidAuthorization_,
+} from './Authorize.js'
import { charge as charge_ } from './Charge.js'
import { session as session_, settle as settle_ } from './Session.js'
import { renew as renewSubscription_, subscription as subscription_ } from './Subscription.js'
@@ -25,10 +31,18 @@ export function tempo(parameters?: pa
export namespace tempo {
export type Parameters = charge_.Parameters & session_.Parameters
+ /** Creates a Tempo `authorize` method for deferred TIP-20 captures. */
+ export const authorize = authorize_
+ /** Captures value against a Tempo authorization. */
+ export const capture = capture_
+ /** Captures value against a Tempo authorization. */
+ export const captureAuthorization = capture_
/** Creates a Tempo `charge` method for one-time TIP-20 token transfers. */
export const charge = charge_
/** Creates a Tempo `session` method for session-based TIP-20 token payments. */
export const session = session_
+ /** TIP20EscrowChannel precompile primitives for opt-in session implementations. */
+ export const precompile = Precompile_
/** Creates a Tempo `subscription` method for recurring TIP-20 token payments. */
export const subscription = subscription_
/** Renews an overdue Tempo subscription outside of the HTTP request path. */
@@ -37,4 +51,6 @@ export namespace tempo {
export const settle = settle_
/** Experimental websocket helpers for Tempo sessions. */
export const Ws = Ws_
+ /** Voids a Tempo authorization without increasing captured value. */
+ export const voidAuthorization = voidAuthorization_
}
diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts
index 6e09a29b..890cad2e 100644
--- a/src/tempo/server/Session.test.ts
+++ b/src/tempo/server/Session.test.ts
@@ -5562,31 +5562,35 @@ describe('monotonicity and TOCTOU (unit tests)', () => {
function seedChannel(
store: ChannelStore.ChannelStore,
- overrides: Partial = {},
+ overrides: Partial = {},
) {
- return store.updateChannel(testChannelId, () => ({
- channelId: testChannelId,
- payer: '0x0000000000000000000000000000000000000001' as Address,
- payee: '0x0000000000000000000000000000000000000002' as Address,
- token: '0x0000000000000000000000000000000000000003' as Address,
- authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
- chainId: 42431,
- escrowContract: escrowContractDefaults[chainIdDefaults.testnet] as Address,
- deposit: 10000000n,
- settledOnChain: 0n,
- highestVoucherAmount: 5000000n,
- highestVoucher: {
- channelId: testChannelId,
- cumulativeAmount: 5000000n,
- signature: '0xdeadbeef' as Hex,
- },
- spent: 0n,
- units: 0,
- closeRequestedAt: 0n,
- finalized: false,
- createdAt: new Date().toISOString(),
- ...overrides,
- }))
+ return store.updateChannel(
+ testChannelId,
+ () =>
+ ({
+ channelId: testChannelId,
+ payer: '0x0000000000000000000000000000000000000001' as Address,
+ payee: '0x0000000000000000000000000000000000000002' as Address,
+ token: '0x0000000000000000000000000000000000000003' as Address,
+ authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
+ chainId: 42431,
+ escrowContract: escrowContractDefaults[chainIdDefaults.testnet] as Address,
+ deposit: 10000000n,
+ settledOnChain: 0n,
+ highestVoucherAmount: 5000000n,
+ highestVoucher: {
+ channelId: testChannelId,
+ cumulativeAmount: 5000000n,
+ signature: '0xdeadbeef' as Hex,
+ },
+ spent: 0n,
+ units: 0,
+ closeRequestedAt: 0n,
+ finalized: false,
+ createdAt: new Date().toISOString(),
+ ...overrides,
+ }) as ChannelStore.State,
+ )
}
test('charge does not allow highestVoucherAmount to decrease', async () => {
diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts
index abb31009..73a9286c 100644
--- a/src/tempo/server/Session.ts
+++ b/src/tempo/server/Session.ts
@@ -57,7 +57,7 @@ import { captureRequestBodyProbe, isSessionContentRequest } from './internal/req
import * as Transport from './internal/transport.js'
/** Challenge methodDetails shape for session methods. */
-type SessionMethodDetails = {
+export type SessionMethodDetails = {
escrowContract: Address
chainId: number
channelId?: Hex | undefined
diff --git a/src/tempo/server/index.ts b/src/tempo/server/index.ts
index b556c971..10bcb172 100644
--- a/src/tempo/server/index.ts
+++ b/src/tempo/server/index.ts
@@ -1,6 +1,7 @@
export * as ChannelStore from '../session/ChannelStore.js'
export * as Sse from '../session/Sse.js'
export * as Ws from '../session/Ws.js'
+export { authorize, capture, voidAuthorization } from './Authorize.js'
export { charge } from './Charge.js'
export { tempo } from './Methods.js'
export { session, settle } from './Session.js'
diff --git a/src/tempo/session/ChannelStore.test.ts b/src/tempo/session/ChannelStore.test.ts
index 6fe9944d..a9f3ec47 100644
--- a/src/tempo/session/ChannelStore.test.ts
+++ b/src/tempo/session/ChannelStore.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'vp/test'
import * as Store from '../../Store.js'
import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
+import * as PrecompileChannel from '../precompile/Channel.js'
import * as ChannelStore from './ChannelStore.js'
const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
@@ -12,7 +13,26 @@ const mixedCaseAliasChannelId = lowerCaseAliasChannelId.replace(/[a-f]/g, (chara
index % 2 === 0 ? character.toUpperCase() : character,
) as Hex
-function makeChannel(overrides?: Partial): ChannelStore.State {
+const precompileDescriptor = {
+ payer: '0x0000000000000000000000000000000000000001' as Address,
+ payee: '0x0000000000000000000000000000000000000002' as Address,
+ operator: '0x0000000000000000000000000000000000000005' as Address,
+ token: '0x0000000000000000000000000000000000000003' as Address,
+ salt: `0x${'11'.repeat(32)}` as Hex,
+ authorizedSigner: '0x0000000000000000000000000000000000000004' as Address,
+ expiringNonceHash: `0x${'22'.repeat(32)}` as Hex,
+} satisfies PrecompileChannel.ChannelDescriptor
+
+type ContractChannelOverrides = Partial &
+ Partial
+type PrecompileChannelOverrides = Partial &
+ ChannelStore.PrecompileBackendState
+
+type ChannelOverrides = ContractChannelOverrides | PrecompileChannelOverrides
+
+function makeChannel(overrides?: ContractChannelOverrides): ChannelStore.State
+function makeChannel(overrides: PrecompileChannelOverrides): ChannelStore.State
+function makeChannel(overrides?: ChannelOverrides): ChannelStore.State {
return {
channelId,
payer: '0x0000000000000000000000000000000000000001' as Address,
@@ -31,14 +51,18 @@ function makeChannel(overrides?: Partial): ChannelStore.Stat
finalized: false,
createdAt: '2025-01-01T00:00:00.000Z',
...overrides,
- }
+ } as ChannelStore.State
}
function seedChannel(
store: ChannelStore.ChannelStore,
- overrides?: Partial,
+ overrides?: ChannelOverrides,
): Promise {
- return store.updateChannel(channelId, () => makeChannel(overrides))
+ return store.updateChannel(channelId, () => {
+ if (!overrides) return makeChannel()
+ if (overrides.backend === 'precompile') return makeChannel(overrides)
+ return makeChannel(overrides)
+ })
}
function stripUpdateMethod(store: Store.Store | Store.AtomicStore): Store.Store {
@@ -184,6 +208,55 @@ describe('channelStore', () => {
expect(loaded!.highestVoucherAmount).toBe(888_888_888n)
expect(loaded!.spent).toBe(42n)
})
+
+ test('keeps existing contract-backed channels compatible when backend fields are absent', async () => {
+ const cs = ChannelStore.fromStore(Store.memory())
+ await seedChannel(cs)
+
+ const loaded = await cs.getChannel(channelId)
+ expect(loaded).not.toBeNull()
+ expect(ChannelStore.isContractState(loaded!)).toBe(true)
+ expect(loaded!.backend).toBeUndefined()
+ expect('operator' in loaded!).toBe(false)
+ expect('salt' in loaded!).toBe(false)
+ expect('expiringNonceHash' in loaded!).toBe(false)
+ expect('descriptor' in loaded!).toBe(false)
+ })
+
+ test('supports explicit contract-backed channel state', async () => {
+ const cs = ChannelStore.fromStore(Store.memory())
+ await seedChannel(cs, { backend: 'contract' })
+
+ const loaded = await cs.getChannel(channelId)
+ expect(loaded).not.toBeNull()
+ expect(ChannelStore.isContractState(loaded!)).toBe(true)
+ expect(loaded!.backend).toBe('contract')
+ })
+
+ test('persists precompile descriptor fields without affecting accounting', async () => {
+ const cs = ChannelStore.fromStore(Store.memory())
+ await seedChannel(cs, {
+ backend: 'precompile',
+ operator: precompileDescriptor.operator,
+ salt: precompileDescriptor.salt,
+ expiringNonceHash: precompileDescriptor.expiringNonceHash,
+ descriptor: precompileDescriptor,
+ })
+
+ const deducted = await ChannelStore.deductFromChannel(cs, channelId, 1_000_000n)
+ expect(deducted.ok).toBe(true)
+
+ const loaded = await cs.getChannel(channelId)
+ expect(ChannelStore.isPrecompileState(loaded!)).toBe(true)
+ if (!ChannelStore.isPrecompileState(loaded!)) throw new Error('expected precompile channel')
+ expect(loaded!.backend).toBe('precompile')
+ expect(loaded!.operator).toBe(precompileDescriptor.operator)
+ expect(loaded!.salt).toBe(precompileDescriptor.salt)
+ expect(loaded!.expiringNonceHash).toBe(precompileDescriptor.expiringNonceHash)
+ expect(loaded!.descriptor).toEqual(precompileDescriptor)
+ expect(loaded!.spent).toBe(1_000_000n)
+ expect(loaded!.units).toBe(1)
+ })
})
describe('waitForUpdate', () => {
diff --git a/src/tempo/session/ChannelStore.ts b/src/tempo/session/ChannelStore.ts
index 30bf9a6a..db5692e4 100644
--- a/src/tempo/session/ChannelStore.ts
+++ b/src/tempo/session/ChannelStore.ts
@@ -1,6 +1,7 @@
import type { Address, Hex } from 'viem'
import type * as Store from '../../Store.js'
+import type * as PrecompileChannel from '../precompile/Channel.js'
import type { SignedVoucher } from './Types.js'
/**
@@ -19,7 +20,31 @@ import type { SignedVoucher } from './Types.js'
* - `settledOnChain` only increases
* - `deposit` reflects the latest on-chain value
*/
-export interface State {
+export type State = BaseState & BackendState
+
+export type BackendState = ContractBackendState | PrecompileBackendState
+
+/** State for a smart-contract-backed payment channel. */
+export interface ContractBackendState {
+ /** Channel backend. Omitted for existing contract-backed records. */
+ backend?: 'contract' | undefined
+}
+
+/** State for a TIP20EscrowChannel precompile-backed payment channel. */
+export interface PrecompileBackendState {
+ /** Channel backend. */
+ backend: 'precompile'
+ /** Descriptor used to derive the channel's identity. */
+ descriptor: PrecompileChannel.ChannelDescriptor
+ /** Transaction-bound nonce hash used to derive the channel's identity. */
+ expiringNonceHash: Hex
+ /** Address authorized to operate the channel. */
+ operator: Address
+ /** Salt used to derive the channel's identity. */
+ salt: Hex
+}
+
+export interface BaseState {
/** Address authorized to sign vouchers on behalf of the payer. */
authorizedSigner: Address
/** Chain ID the channel was opened on. */
@@ -54,6 +79,16 @@ export interface State {
units: number
}
+/** Returns whether a channel is backed by the TIP20EscrowChannel precompile. */
+export function isPrecompileState(state: State): state is BaseState & PrecompileBackendState {
+ return state.backend === 'precompile'
+}
+
+/** Returns whether a channel is backed by the smart contract escrow. */
+export function isContractState(state: State): state is BaseState & ContractBackendState {
+ return state.backend === undefined || state.backend === 'contract'
+}
+
/**
* Internal store interface for channel state persistence.
*
diff --git a/test/setup.ts b/test/setup.ts
index a45d8fcb..61489c44 100644
--- a/test/setup.ts
+++ b/test/setup.ts
@@ -1,3 +1,8 @@
+import * as node_crypto from 'node:crypto'
+import * as node_fs from 'node:fs/promises'
+import * as node_os from 'node:os'
+import * as node_path from 'node:path'
+
import { createClient, http as viem_http, parseUnits } from 'viem'
import { sendTransactionSync } from 'viem/actions'
import { Actions, Addresses } from 'viem/tempo'
@@ -8,9 +13,13 @@ import { rpcUrl } from './tempo/prool.js'
import { accounts, asset, chain, client, fundAccount } from './tempo/viem.js'
const setupTimeoutMs = 120_000
+const setupLockStaleAfterMs = 2 * setupTimeoutMs
const warmupAttempts = 5
const warmupRetryDelayMs = 1_000
const warmupRequestTimeoutMs = 10_000
+const devnetFaucetAttempts = 3
+const devnetFaucetRetryDelayMs = 3_000
+const devnetFaucetRequestTimeoutMs = 30_000
const warmupClient = createClient({
account: accounts[0],
@@ -25,11 +34,115 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+const localnetSetupKey = node_crypto.createHash('sha256').update(rpcUrl).digest('hex').slice(0, 16)
+const localnetSetupDir = node_path.join(node_os.tmpdir(), `mppx-localnet-setup-${localnetSetupKey}`)
+const localnetSetupDone = node_path.join(localnetSetupDir, 'done')
+const localnetSetupLock = node_path.join(localnetSetupDir, 'lock')
+const devnetSetupKey = node_crypto.createHash('sha256').update(rpcUrl).digest('hex').slice(0, 16)
+const devnetSetupDir = node_path.join(node_os.tmpdir(), `mppx-devnet-setup-${devnetSetupKey}`)
+const devnetSetupLock = node_path.join(devnetSetupDir, 'lock')
+
+type SetupLockMetadata = {
+ createdAt: number
+ pid: number
+ rpcUrl: string
+}
+
+async function exists(path: string) {
+ try {
+ await node_fs.access(path)
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function writeSetupLockMetadata(path: string) {
+ const metadata = {
+ createdAt: Date.now(),
+ pid: process.pid,
+ rpcUrl,
+ } satisfies SetupLockMetadata
+ await node_fs.writeFile(node_path.join(path, 'metadata.json'), JSON.stringify(metadata))
+}
+
+function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function isStaleSetupLock(path: string): Promise {
+ try {
+ const raw = await node_fs.readFile(node_path.join(path, 'metadata.json'), 'utf8')
+ const metadata = JSON.parse(raw) as Partial
+ if (typeof metadata.createdAt !== 'number') return true
+ if (Date.now() - metadata.createdAt > setupLockStaleAfterMs) return true
+ if (typeof metadata.pid !== 'number') return true
+ return !isProcessAlive(metadata.pid)
+ } catch {
+ return true
+ }
+}
+
+async function acquireSetupLock(path: string, done?: string | undefined) {
+ for (;;) {
+ try {
+ await node_fs.mkdir(path)
+ await writeSetupLockMetadata(path)
+ return
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
+ if (done && (await exists(done))) return
+ if (await isStaleSetupLock(path)) {
+ await node_fs.rm(path, { recursive: true, force: true })
+ continue
+ }
+ await sleep(250)
+ }
+ }
+}
+
+async function runLocalnetSetupLocked(fn: () => Promise) {
+ await node_fs.mkdir(localnetSetupDir, { recursive: true })
+ if (await exists(localnetSetupDone)) return
+
+ await acquireSetupLock(localnetSetupLock, localnetSetupDone)
+ if (await exists(localnetSetupDone)) return
+
+ try {
+ if (await exists(localnetSetupDone)) return
+ await fn()
+ await node_fs.writeFile(localnetSetupDone, new Date().toISOString())
+ } finally {
+ await node_fs.rm(localnetSetupLock, { recursive: true, force: true })
+ }
+}
+
+async function runDevnetSetupLocked(fn: () => Promise) {
+ await node_fs.mkdir(devnetSetupDir, { recursive: true })
+ await acquireSetupLock(devnetSetupLock)
+
+ try {
+ await fn()
+ } finally {
+ await node_fs.rm(devnetSetupLock, { recursive: true, force: true })
+ }
+}
+
type LocalnetSetupState = {
done: boolean
promise: Promise | undefined
}
+type DevnetSetupState = {
+ done: boolean
+ promise: Promise | undefined
+}
+
const localnetSetupState = (() => {
const globalState = globalThis as typeof globalThis & {
__mppxLocalnetSetup__?: LocalnetSetupState
@@ -40,6 +153,16 @@ const localnetSetupState = (() => {
})
})()
+const devnetSetupState = (() => {
+ const globalState = globalThis as typeof globalThis & {
+ __mppxDevnetSetup__?: DevnetSetupState
+ }
+ return (globalState.__mppxDevnetSetup__ ??= {
+ done: false,
+ promise: undefined,
+ })
+})()
+
async function warmupLocalnet() {
let lastError: unknown
@@ -57,8 +180,52 @@ async function warmupLocalnet() {
throw lastError
}
+async function fundDevnetAccount(account: (typeof accounts)[number]) {
+ let lastError: unknown
+
+ for (let attempt = 1; attempt <= devnetFaucetAttempts; attempt++) {
+ try {
+ await Actions.faucet.fundSync(client, {
+ account,
+ timeout: devnetFaucetRequestTimeoutMs,
+ })
+ return
+ } catch (error) {
+ lastError = error
+ if (attempt === devnetFaucetAttempts) break
+ await sleep(devnetFaucetRetryDelayMs)
+ }
+ }
+
+ throw lastError
+}
+
beforeAll(async () => {
- if (nodeEnv !== 'localnet') return
+ if (nodeEnv !== 'localnet' && nodeEnv !== 'devnet') return
+
+ if (nodeEnv === 'devnet') {
+ if (devnetSetupState.done) return
+ if (devnetSetupState.promise) {
+ await devnetSetupState.promise
+ return
+ }
+
+ devnetSetupState.promise = runDevnetSetupLocked(async () => {
+ // Fund deterministic test accounts used by the precompile/session suites.
+ // The devnet faucet funds the chain's configured test TIP-20 tokens.
+ for (const account of [accounts[0], accounts[1], accounts[2]])
+ await fundDevnetAccount(account)
+ devnetSetupState.done = true
+ })
+
+ try {
+ await devnetSetupState.promise
+ } catch (error) {
+ devnetSetupState.promise = undefined
+ throw error
+ }
+ return
+ }
if (localnetSetupState.done) return
if (localnetSetupState.promise) {
@@ -66,29 +233,31 @@ beforeAll(async () => {
return
}
- localnetSetupState.promise = (async () => {
+ localnetSetupState.promise = runLocalnetSetupLocked(async () => {
// Send noop tx to trigger block.
await warmupLocalnet()
- // Mint liquidity for fee tokens.
- await Promise.all(
- [1n, 2n, 3n].map((id) =>
- Actions.amm.mintSync(client, {
- account: accounts[0],
- feeToken: Addresses.pathUsd,
- nonceKey: 'expiring',
- userTokenAddress: id,
- validatorTokenAddress: Addresses.pathUsd,
- validatorTokenAmount: parseUnits('1000', 6),
- to: accounts[0].address,
- }),
- ),
- )
+ // Mint liquidity for fee tokens. Keep setup transactions sequential so
+ // externally managed localnet/devnet/testnet/mainnet RPCs cannot race nonce
+ // assignment for the shared funding account.
+ for (const id of [1n, 2n, 3n]) {
+ await Actions.amm.mintSync(client, {
+ account: accounts[0],
+ feeToken: Addresses.pathUsd,
+ nonceKey: 'expiring',
+ userTokenAddress: id,
+ validatorTokenAddress: Addresses.pathUsd,
+ validatorTokenAmount: parseUnits('1000', 6),
+ to: accounts[0].address,
+ })
+ }
await fundAccount({ address: accounts[1].address, token: asset })
await fundAccount({ address: accounts[2].address, token: asset })
+ await fundAccount({ address: accounts[1].address, token: Addresses.pathUsd })
+ await fundAccount({ address: accounts[2].address, token: Addresses.pathUsd })
localnetSetupState.done = true
- })()
+ })
try {
await localnetSetupState.promise
diff --git a/test/tempo/viem.ts b/test/tempo/viem.ts
index 116d6e32..d4f15c94 100644
--- a/test/tempo/viem.ts
+++ b/test/tempo/viem.ts
@@ -1,5 +1,11 @@
import type * as Hex from 'ox/Hex'
-import { createClient, defineChain, type HttpTransportConfig, http as viem_http } from 'viem'
+import {
+ createClient,
+ defineChain,
+ type Chain as viem_Chain,
+ type HttpTransportConfig,
+ http as viem_http,
+} from 'viem'
import { english, generateMnemonic, type LocalAccount, mnemonicToAccount } from 'viem/accounts'
import { tempo, tempoDevnet, tempoLocalnet, tempoModerato } from 'viem/chains'
import { Actions } from 'viem/tempo'
@@ -18,7 +24,8 @@ const localnetTransportOptions =
: undefined
const accountsMnemonic = (() => {
- if (nodeEnv === 'localnet') return 'test test test test test test test test test test test junk'
+ if (nodeEnv === 'localnet' || nodeEnv === 'devnet')
+ return 'test test test test test test test test test test test junk'
return generateMnemonic(english)
})()
@@ -28,14 +35,28 @@ export const accounts = Array.from({ length: 20 }, (_, i) =>
}),
) as unknown as FixedArray
+function withRpcUrl(chain: chain): chain {
+ if (!import.meta.env.VITE_RPC_URL) return chain
+ return defineChain({
+ ...chain,
+ rpcUrls: {
+ ...chain.rpcUrls,
+ default: {
+ ...chain.rpcUrls.default,
+ http: [rpcUrl],
+ },
+ },
+ }) as unknown as chain
+}
+
export const chain = (() => {
switch (nodeEnv) {
case 'mainnet':
- return tempo
+ return withRpcUrl(tempo)
case 'testnet':
- return tempoModerato
+ return withRpcUrl(tempoModerato)
case 'devnet':
- return tempoDevnet
+ return withRpcUrl(tempoDevnet)
default:
return defineChain({
...tempoLocalnet,